diff --git a/.architecture/2026-07-10-bec433e1/diagrams-bpmn/crawl-flow.bpmn b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/crawl-flow.bpmn new file mode 100644 index 000000000..715e2dbec --- /dev/null +++ b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/crawl-flow.bpmn @@ -0,0 +1,190 @@ + + + + + f_0 + f_3 + + + + f_0 + f_1 + + + f_1 + f_2 + f_6 + + + + f_6 + + + + f_2 + f_8 + + + f_3 + f_4 + + + f_4 + f_5 + f_7 + + + f_7 + + + + f_5 + f_9 + + + f_8 + f_9 + f_11 + + + f_11 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.architecture/2026-07-10-bec433e1/diagrams-bpmn/crawl-flow.png b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/crawl-flow.png new file mode 100644 index 000000000..569d6837b Binary files /dev/null and b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/crawl-flow.png differ diff --git a/.architecture/2026-07-10-bec433e1/diagrams-bpmn/extract-flow.bpmn b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/extract-flow.bpmn new file mode 100644 index 000000000..060ed6e3f --- /dev/null +++ b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/extract-flow.bpmn @@ -0,0 +1,250 @@ + + + + + f_0 + f_3 + + + + f_0 + f_1 + + + f_1 + f_2 + f_6 + + + + f_6 + + + + f_2 + f_12 + f_17 + + + f_3 + f_4 + + + f_4 + f_5 + f_7 + + + f_7 + + + + f_5 + f_13 + f_18 + + + + + f_12 + f_13 + + + + + + + + + f_17 + + + + f_18 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.architecture/2026-07-10-bec433e1/diagrams-bpmn/extract-flow.png b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/extract-flow.png new file mode 100644 index 000000000..5b420ecfc Binary files /dev/null and b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/extract-flow.png differ diff --git a/.architecture/2026-07-10-bec433e1/diagrams-bpmn/legend.bpmn b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/legend.bpmn new file mode 100644 index 000000000..038e10f54 --- /dev/null +++ b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/legend.bpmn @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.architecture/2026-07-10-bec433e1/diagrams-bpmn/legend.png b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/legend.png new file mode 100644 index 000000000..8945d5ef7 Binary files /dev/null and b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/legend.png differ diff --git a/.architecture/2026-07-10-bec433e1/diagrams-bpmn/ownership-and-seams.bpmn b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/ownership-and-seams.bpmn new file mode 100644 index 000000000..56ed46936 --- /dev/null +++ b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/ownership-and-seams.bpmn @@ -0,0 +1,264 @@ + + + + + f_1 + + + + f_1 + + + + + + f_9 + + + f_10 + + + f_2 + f_3 + + + f_2 + f_3 + f_4 + f_5 + + + f_6 + f_7 + f_8 + + + f_4 + f_9 + + + f_5 + f_6 + f_11 + + + f_7 + f_12 + + + f_8 + f_13 + + + f_10 + f_11 + f_12 + f_13 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.architecture/2026-07-10-bec433e1/diagrams-bpmn/ownership-and-seams.png b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/ownership-and-seams.png new file mode 100644 index 000000000..035c76cc8 Binary files /dev/null and b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/ownership-and-seams.png differ diff --git a/.architecture/2026-07-10-bec433e1/diagrams-bpmn/receive-flow.bpmn b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/receive-flow.bpmn new file mode 100644 index 000000000..f2146fdb3 --- /dev/null +++ b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/receive-flow.bpmn @@ -0,0 +1,253 @@ + + + + + f_0 + + + f_0 + f_2 + + + + f_2 + f_3 + f_5 + + + + f_3 + f_4 + f_7 + + + + f_7 + + + + f_4 + f_9 + + + f_5 + f_6 + f_8 + + + f_8 + + + + f_6 + f_10 + + + f_9 + f_10 + f_12 + + + + f_12 + f_14 + + + + f_14 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.architecture/2026-07-10-bec433e1/diagrams-bpmn/receive-flow.png b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/receive-flow.png new file mode 100644 index 000000000..7e72e8141 Binary files /dev/null and b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/receive-flow.png differ diff --git a/.architecture/2026-07-10-bec433e1/diagrams-bpmn/web-routes.bpmn b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/web-routes.bpmn new file mode 100644 index 000000000..7e2371d51 --- /dev/null +++ b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/web-routes.bpmn @@ -0,0 +1,137 @@ + + + + + f_0 + + + f_0 + f_1 + f_3 + + + f_1 + f_2 + + + f_2 + f_9 + + + f_3 + + + + + + + + f_9 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.architecture/2026-07-10-bec433e1/diagrams-bpmn/web-routes.png b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/web-routes.png new file mode 100644 index 000000000..074e220e3 Binary files /dev/null and b/.architecture/2026-07-10-bec433e1/diagrams-bpmn/web-routes.png differ diff --git a/.architecture/2026-07-10-bec433e1/diagrams/crawl-flow.svg b/.architecture/2026-07-10-bec433e1/diagrams/crawl-flow.svg new file mode 100644 index 000000000..70d5f63cc --- /dev/null +++ b/.architecture/2026-07-10-bec433e1/diagrams/crawl-flow.svg @@ -0,0 +1 @@ +

genuine fault only

genuine fault only

unsafe url → failed

ok

fetched

fetched → crawled / else failed + reason

CrawlEmailLinkPreview
userId, receivedAtMessageId, ordinal, url

crawl-email-link-preview-rule (hutch, current)

crawl-email-link-preview SQS (hutch, current)

crawl-email-link-preview DLQ + alarm

crawl-email-link-preview Lambda (hutch, current)

inbox-crawl-email-link-preview-rule

inbox-crawl-email-link-preview SQS

inbox-crawl-email-link-preview DLQ + alarm

inbox-crawl-email-link-preview Lambda
same handler code, re-homed

validateSaveableUrl +
isBlockedIpAddress on every hop

crawlAndFinalizeArticle
metadata only — body discarded

shared content bucket
lead-image upload, served via
save-link content-media CDN

hutch-inbox-email-links
setLinkOutcome UpdateItem
crawled / failed — converging, last write wins

\ No newline at end of file diff --git a/.architecture/2026-07-10-bec433e1/diagrams/extract-flow.svg b/.architecture/2026-07-10-bec433e1/diagrams/extract-flow.svg new file mode 100644 index 000000000..e52bddd45 --- /dev/null +++ b/.architecture/2026-07-10-bec433e1/diagrams/extract-flow.svg @@ -0,0 +1 @@ +

maxReceiveCount

maxReceiveCount

each url

if truncated

if truncated

EmailReceivedEvent
userId, receivedAtMessageId, recipientAddress
(published twice during overlap)

email-received-rule (hutch, current)
source hutch.inbox / EmailReceived

extract-email-links SQS (hutch, current)

extract-email-links DLQ + alarm

extract-email-links Lambda (hutch, current)

inbox-extract-email-links-rule
same pattern, explicit distinct name

inbox-extract-email-links SQS

inbox-extract-email-links DLQ + alarm

inbox-extract-email-links Lambda
same handler code, re-homed

hutch-inbox-emails getEmail
skip unless status = received

raw-email bucket readRawEmail
re-derive body from immutable .eml

parseEmail + deriveSanitizedBody
+ extractUrls + capEmailLinks (soft cap 200)

hutch-inbox-email-links
putLink one pending row per url
conditional — replay = no-op

hutch-inbox-email-links
putLinksMeta LAST — extraction-finished barrier,
truncated flag

CrawlEmailLinkPreview
one per link, per execution

extract-email-links-truncated-alert queue
+ send-rate alarm → SNS email (hutch, current)

inbox-extract-email-links-truncated-alert queue
+ send-rate alarm → SNS email

\ No newline at end of file diff --git a/.architecture/2026-07-10-bec433e1/diagrams/legend.svg b/.architecture/2026-07-10-bec433e1/diagrams/legend.svg new file mode 100644 index 000000000..f2f03c4bd --- /dev/null +++ b/.architecture/2026-07-10-bec433e1/diagrams/legend.svg @@ -0,0 +1 @@ +

Command

System / aggregate

Event

Policy / reaction

Read model / store

Queue

DLQ

Inbox-owned (new in this snapshot)

\ No newline at end of file diff --git a/.architecture/2026-07-10-bec433e1/diagrams/ownership-and-seams.svg b/.architecture/2026-07-10-bec433e1/diagrams/ownership-and-seams.svg new file mode 100644 index 000000000..80dec5954 --- /dev/null +++ b/.architecture/2026-07-10-bec433e1/diagrams/ownership-and-seams.svg @@ -0,0 +1 @@ +

inbox stack — NEW deployable

hutch stack — keeps SES receiving, tables, gateway

inboxNotificationTopicArn

apiGatewayId / execArn / apiUrl

SNS TopicSubscription

routes attached by precedence

InboxMail component
SES domain identity + DKIM + MX
catch-all receipt rule set (active)

raw-email bucket
immutable .eml, kept forever

inbox-mail SNS notification topic

hutch-inbox-addresses
hutch-inbox-emails
hutch-inbox-email-links

shared article-content bucket
sanitized bodies + lead images

API Gateway
hutch SSR Lambda on $default

ORIGINAL consumers still live:
receive-email, extract-email-links,
crawl-email-link-preview
(removed in follow-up cleanup)

stack outputs: apiGatewayId,
apiGatewayExecutionArn, apiUrl,
inboxNotificationTopicArn

StackReference to hutch
(4 deploy-time outputs)

own Pulumi config:
table + bucket names,
contentMediaCdnDomain

inbox-web Lambda
GET /inbox + ANY /inbox/proxy routes

inbox-receive-email
Lambda + SQS + DLQ

inbox-extract-email-links
Lambda + SQS + DLQ + truncation alert

inbox-crawl-email-link-preview
Lambda + SQS + DLQ

platform event bus
(shared, via platformStack reference)

\ No newline at end of file diff --git a/.architecture/2026-07-10-bec433e1/diagrams/receive-flow.svg b/.architecture/2026-07-10-bec433e1/diagrams/receive-flow.svg new file mode 100644 index 000000000..70d3a7ad1 --- /dev/null +++ b/.architecture/2026-07-10-bec433e1/diagrams/receive-flow.svg @@ -0,0 +1 @@ +

maxReceiveCount

maxReceiveCount

no — audit + ACK, never pages

yes

yes — audit rows, then fail record to DLQ

no fault

Forwarded newsletter arrives at in-token@mail-domain

SES catch-all receipt rule (hutch InboxMail)
S3 action + SNS notify

raw-email bucket
inbound/sesMessageId — immutable, forever

inbox-mail SNS topic
TWO SQS subscriptions during overlap

receive-email SQS (hutch, current)

receive-email DLQ + alarm → SNS email

receive-email Lambda (hutch, current)
receive-email-handler.ts

inbox-receive-email SQS

inbox-receive-email DLQ + alarm → SNS email

inbox-receive-email Lambda
same handler code, re-homed

resolve each recipient
findByAddress (GetItem)

hutch-inbox-addresses

deliverable?
known + not disabled

hutch-inbox-emails
audit row, __unrouted__ partition
(unknown / disabled / rejected / unparsed)

oversize or unparseable
with a real enabled recipient?

shared content bucket
sanitized body + inline images
(user-scoped key, written BEFORE row)

hutch-inbox-emails
putEmail conditional — duplicate = no-op

EmailReceivedEvent
published even on duplicate row
×2 during overlap (one per stack)

\ No newline at end of file diff --git a/.architecture/2026-07-10-bec433e1/diagrams/web-routes.svg b/.architecture/2026-07-10-bec433e1/diagrams/web-routes.svg new file mode 100644 index 000000000..e70572619 --- /dev/null +++ b/.architecture/2026-07-10-bec433e1/diagrams/web-routes.svg @@ -0,0 +1 @@ +

route precedence

$default — every other path

cached, decorative

GET /inbox — reader opens the received list

hutch API Gateway (owned by hutch)

route keys GET /inbox + ANY /inbox/proxy
attached by the inbox stack —
more-specific beats $default

inbox-web Lambda
express app, requireAuth on /inbox

hutch SSR Lambda on $default
/inbox router still mounted — shadowed,
removed in follow-up cleanup

sessions table
GetItem each request; UpdateItem on
markSessionEmailVerified

users table
Query userId-index

subscription-providers GetItem
trial / access display

hutch-inbox-addresses / -emails / -email-links
lists, detail, link cards + polling

shared content bucket
sanitized email body for the iframe

changelog banner fragment
same-origin /blog route, fail-open

\ No newline at end of file diff --git a/.architecture/2026-07-10-bec433e1/inbox-split.md b/.architecture/2026-07-10-bec433e1/inbox-split.md new file mode 100644 index 000000000..ac93c6adc --- /dev/null +++ b/.architecture/2026-07-10-bec433e1/inbox-split.md @@ -0,0 +1,509 @@ +# Inbox deployable split — inbound-email pipeline during the double-delivery overlap + +> **Commit:** `bec433e1` · **Date:** 2026-07-10 · **Branch:** `inbox-split` +> **Subject:** feat(hutch): export the inbox notification topic ARN for the inbox deployable + +The inbound-email pipeline (M2 receive + M3 link previews) is mid-extraction +from the `hutch` deployable into a new **`projects/inbox`** deployable. This +snapshot captures the deliberately overlapped state: + +- **`inbox` (new)** carries its own copies of the three consumers — + `inbox-receive-email` (SNS→SQS), `inbox-extract-email-links` (subscribes + `EmailReceivedEvent`, rule `inbox-extract-email-links-rule`) and + `inbox-crawl-email-link-preview` (subscribes `CrawlEmailLinkPreview`, rule + `inbox-crawl-email-link-preview-rule`) — plus an `inbox-web` Lambda that + takes over the `/inbox` pages on hutch's API Gateway by route precedence. +- **`hutch` (current)** still runs its original consumers (`receive-email`, + `extract-email-links`, `crawl-email-link-preview`), their queues, their + EventBridge rules and its SNS subscription, and still mounts the `/inbox` + router in its SSR Lambda (now shadowed). These are scheduled for removal in + the follow-up cleanup commit. +- **`hutch` keeps owning** SES receiving (`InboxMail`: domain identity + DKIM + + MX, the catch-all receipt rule set, the immutable raw-email bucket, the SNS + notification topic) and the three DynamoDB tables + (`hutch-inbox-addresses`, `hutch-inbox-emails`, `hutch-inbox-email-links`). + The topic ARN is exported cross-stack (`inboxNotificationTopicArn`) for the + inbox stack's SNS subscription. + +During the window every stage is **delivered twice** (once per stack). That is +safe by design: every handler is replay-idempotent (conditional puts, no-op +duplicates, terminal `UpdateItem`s), so the overlap costs duplicate compute, +never duplicate state. Physical names are `inbox-`-prefixed throughout because +EventBridge `PutRule` is an upsert and Lambda/SQS names are account-scoped — a +same-named resource would *steal* hutch's live rule/queue instead of standing +up beside it. + +> File paths in this document are accurate as of this commit and may later move. + +--- + +## Legend + +![Legend](diagrams/legend.svg) + +
Mermaid source + +```mermaid +flowchart LR + cmd[Command]:::command + sys[System / aggregate]:::system + evt[Event]:::event + pol[Policy / reaction]:::policy + rm[(Read model / store)]:::store + q[Queue]:::queue + dlq[DLQ]:::dlq + new["Inbox-owned (new in this snapshot)"]:::new + + classDef command fill:#a6d8ff,stroke:#1e6fb8,color:#000; + classDef system fill:#fff2a8,stroke:#a08a00,color:#000; + classDef event fill:#ffb976,stroke:#a85800,color:#000; + classDef policy fill:#d6b8ff,stroke:#6b3fb0,color:#000; + classDef store fill:#b8e8c5,stroke:#2f7a45,color:#000; + classDef queue fill:#e8e8e8,stroke:#666,color:#000; + classDef dlq fill:#f8c8c8,stroke:#a83434,color:#000; + classDef new fill:#ffd24c,stroke:#a0660b,stroke-width:3px,color:#000; +``` + +
+ +Everything owned by the new `inbox` deployable is gold (`:::new`). Hutch's +still-live originals keep their normal role colours — they are **current** +production infrastructure until the cleanup commit removes them. + +--- + +## Ownership map & cross-stack seams + +`hutch` remains the owner of everything a mail *arrives through* (SES is +account/region-singular for receiving — exactly one active receipt rule set) +and of the three tables both stacks read/write. The `inbox` stack attaches to +hutch through exactly four deploy-time StackReference outputs; everything else +it needs (table names, bucket names, the content-media CDN domain) is a config +constant read from its own Pulumi config, so deploy order is only coupled where +it genuinely must be (`hutch` deploys first so the outputs exist). Both stacks +resolve the same platform event bus via `HutchEventBus.fromPlatformStack`. + +![Ownership map](diagrams/ownership-and-seams.svg) + +
Mermaid source + +```mermaid +flowchart LR + classDef command fill:#a6d8ff,stroke:#1e6fb8,color:#000; + classDef system fill:#fff2a8,stroke:#a08a00,color:#000; + classDef event fill:#ffb976,stroke:#a85800,color:#000; + classDef policy fill:#d6b8ff,stroke:#6b3fb0,color:#000; + classDef store fill:#b8e8c5,stroke:#2f7a45,color:#000; + classDef queue fill:#e8e8e8,stroke:#666,color:#000; + classDef dlq fill:#f8c8c8,stroke:#a83434,color:#000; + classDef new fill:#ffd24c,stroke:#a0660b,stroke-width:3px,color:#000; + + subgraph hutchStack ["hutch stack — keeps SES receiving, tables, gateway"] + inboxMail["InboxMail component
SES domain identity + DKIM + MX
catch-all receipt rule set (active)"]:::system + rawBucket[("raw-email bucket
immutable .eml, kept forever")]:::store + snsTopic["inbox-mail SNS notification topic"]:::queue + tables[("hutch-inbox-addresses
hutch-inbox-emails
hutch-inbox-email-links")]:::store + contentBucket[("shared article-content bucket
sanitized bodies + lead images")]:::store + gateway["API Gateway
hutch SSR Lambda on $default"]:::system + oldConsumers["ORIGINAL consumers still live:
receive-email, extract-email-links,
crawl-email-link-preview
(removed in follow-up cleanup)"]:::system + exports["stack outputs: apiGatewayId,
apiGatewayExecutionArn, apiUrl,
inboxNotificationTopicArn"]:::policy + end + + subgraph inboxStack ["inbox stack — NEW deployable"] + stackRef["StackReference to hutch
(4 deploy-time outputs)"]:::new + cfg["own Pulumi config:
table + bucket names,
contentMediaCdnDomain"]:::new + webL["inbox-web Lambda
GET /inbox + ANY /inbox/proxy routes"]:::new + recvL["inbox-receive-email
Lambda + SQS + DLQ"]:::new + extractL["inbox-extract-email-links
Lambda + SQS + DLQ + truncation alert"]:::new + crawlL["inbox-crawl-email-link-preview
Lambda + SQS + DLQ"]:::new + end + + bus["platform event bus
(shared, via platformStack reference)"]:::queue + + inboxMail --> rawBucket + inboxMail --> snsTopic + exports -. "inboxNotificationTopicArn" .-> stackRef + exports -. "apiGatewayId / execArn / apiUrl" .-> stackRef + stackRef --> webL + stackRef -. "SNS TopicSubscription" .-> recvL + cfg --> recvL + cfg --> extractL + cfg --> crawlL + webL -. "routes attached by precedence" .-> gateway + oldConsumers <--> bus + recvL <--> bus + extractL <--> bus + crawlL <--> bus + oldConsumers --> tables + recvL --> tables + extractL --> tables + crawlL --> tables + webL --> tables + recvL --> contentBucket + crawlL --> contentBucket +``` + +
+ +--- + +## Receive flow — SES → SNS → two SQS subscriptions (double delivery starts here) + +SES cannot target SQS directly, so the catch-all receipt rule stores the raw +`.eml` to S3 (`inbound/`) and publishes the receipt to the SNS +topic. During the overlap the topic has **two** raw-delivery SQS subscriptions — +hutch's original `receive-email` queue and the new `inbox-receive-email` queue — +so **both** receive workers (same handler code, +`receive-email-handler.ts`) process every inbound mail. Each queue's policy +admits only the topic; each worker's queue matches its 120 s timeout so an +in-flight parse is never redelivered. + +The handler's ordering makes the replay safe: sanitized **body to S3 before the +row, row before the event** — and it re-publishes `EmailReceivedEvent` even +when `putEmail` reports a duplicate (a crash between row and publish must not +lose the event; consumers are idempotent). Net effect during the overlap: +`EmailReceivedEvent` is published **twice per deliverable recipient** (once per +stack), on top of identical rows. + +Expected catch-all-MX conditions never page: unknown recipient, disabled +recipient, and oversize/unparseable mail addressed *only* to unknown/disabled +addresses each record an auditable row (unrouted partition `__unrouted__`) and +ACK. A whole-message fault (oversize > 20 MiB, unparseable MIME) pages — fails +the record to the DLQ so the alarm emails the operator — **only when a real, +enabled recipient lost mail**. A body that sanitizes to nothing persists an +`unparsed` row (no event) so the UI shows its graceful panel. + +![Receive flow](diagrams/receive-flow.svg) + +
Mermaid source + +```mermaid +flowchart TD + classDef command fill:#a6d8ff,stroke:#1e6fb8,color:#000; + classDef system fill:#fff2a8,stroke:#a08a00,color:#000; + classDef event fill:#ffb976,stroke:#a85800,color:#000; + classDef policy fill:#d6b8ff,stroke:#6b3fb0,color:#000; + classDef store fill:#b8e8c5,stroke:#2f7a45,color:#000; + classDef queue fill:#e8e8e8,stroke:#666,color:#000; + classDef dlq fill:#f8c8c8,stroke:#a83434,color:#000; + classDef new fill:#ffd24c,stroke:#a0660b,stroke-width:3px,color:#000; + + mail(["Forwarded newsletter arrives at in-token@mail-domain"]):::command + rule["SES catch-all receipt rule (hutch InboxMail)
S3 action + SNS notify"]:::system + raw[("raw-email bucket
inbound/sesMessageId — immutable, forever")]:::store + topic["inbox-mail SNS topic
TWO SQS subscriptions during overlap"]:::queue + + hq["receive-email SQS (hutch, current)"]:::queue + hdlq["receive-email DLQ + alarm → SNS email"]:::dlq + hl["receive-email Lambda (hutch, current)
receive-email-handler.ts"]:::system + + iq["inbox-receive-email SQS"]:::new + idlq["inbox-receive-email DLQ + alarm → SNS email"]:::dlq + il["inbox-receive-email Lambda
same handler code, re-homed"]:::new + + resolve["resolve each recipient
findByAddress (GetItem)"]:::system + addr[("hutch-inbox-addresses")]:::store + gate{"deliverable?
known + not disabled"}:::policy + audit[("hutch-inbox-emails
audit row, __unrouted__ partition
(unknown / disabled / rejected / unparsed)")]:::store + fault{"oversize or unparseable
with a real enabled recipient?"}:::policy + body[("shared content bucket
sanitized body + inline images
(user-scoped key, written BEFORE row)")]:::store + row[("hutch-inbox-emails
putEmail conditional — duplicate = no-op")]:::store + ere["EmailReceivedEvent
published even on duplicate row
×2 during overlap (one per stack)"]:::event + + mail --> rule + rule --> raw + rule --> topic + topic --> hq --> hl + topic --> iq --> il + hq -. "maxReceiveCount" .-> hdlq + iq -. "maxReceiveCount" .-> idlq + hl --> resolve + il --> resolve + resolve --> addr + resolve --> gate + gate -- "no — audit + ACK, never pages" --> audit + gate -- "yes" --> fault + fault -- "yes — audit rows, then fail record to DLQ" --> audit + fault -- "no fault" --> body + body --> row + row --> ere +``` + +
+ +--- + +## Extract flow — one `EmailReceivedEvent`, two EventBridge rules + +`EmailReceivedEvent` (source `hutch.inbox`, detail-type `EmailReceived`) now +matches **two rules on the shared bus**: hutch's original default-named +`email-received-rule` (targeting hutch's `extract-email-links` queue) and the +explicitly named `inbox-extract-email-links-rule` (targeting the new +`inbox-extract-email-links` queue). The explicit name is what keeps the new +subscription *distinct* — `PutRule` upserts by name, so reusing the event's +default rule name would have re-pointed hutch's live rule instead of adding a +second one. + +Both workers run the same `extract-email-links-handler.ts`: skip unless the row +is `status=received`, **re-derive the body from the immutable raw `.eml`** +(read raw → re-parse → re-sanitize via the shared `deriveSanitizedBody`), +extract URLs, apply the soft cap (200), write one `pending` link row per URL +(conditional put — a replay is a no-op duplicate), fan out one +`CrawlEmailLinkPreview` per link, and write the per-email meta row **last** as +the "extraction finished" barrier. Truncation is a successful degradation: the +first N previews still ship while a message goes to a **stack-local dedicated +alert queue** (never the failure DLQ) whose send-rate alarm emails the +operator — each stack owns its own alert queue + topic + alarm +(`extract-email-links-truncated-alert` current, `inbox-…-truncated-alert` new). + +With the receive stage already publishing the event twice, each extract queue +sees **two copies** — up to four executions per email across both stacks, all +converging on the same rows and (idempotently re-)publishing the same per-link +commands. + +![Extract flow](diagrams/extract-flow.svg) + +
Mermaid source + +```mermaid +flowchart TD + classDef command fill:#a6d8ff,stroke:#1e6fb8,color:#000; + classDef system fill:#fff2a8,stroke:#a08a00,color:#000; + classDef event fill:#ffb976,stroke:#a85800,color:#000; + classDef policy fill:#d6b8ff,stroke:#6b3fb0,color:#000; + classDef store fill:#b8e8c5,stroke:#2f7a45,color:#000; + classDef queue fill:#e8e8e8,stroke:#666,color:#000; + classDef dlq fill:#f8c8c8,stroke:#a83434,color:#000; + classDef new fill:#ffd24c,stroke:#a0660b,stroke-width:3px,color:#000; + + ere["EmailReceivedEvent
userId, receivedAtMessageId, recipientAddress
(published twice during overlap)"]:::event + + hrule["email-received-rule (hutch, current)
source hutch.inbox / EmailReceived"]:::policy + hq["extract-email-links SQS (hutch, current)"]:::queue + hdlq["extract-email-links DLQ + alarm"]:::dlq + hl["extract-email-links Lambda (hutch, current)"]:::system + + irule["inbox-extract-email-links-rule
same pattern, explicit distinct name"]:::new + iq["inbox-extract-email-links SQS"]:::new + idlq["inbox-extract-email-links DLQ + alarm"]:::dlq + il["inbox-extract-email-links Lambda
same handler code, re-homed"]:::new + + getEmail[("hutch-inbox-emails getEmail
skip unless status = received")]:::store + raw[("raw-email bucket readRawEmail
re-derive body from immutable .eml")]:::store + derive["parseEmail + deriveSanitizedBody
+ extractUrls + capEmailLinks (soft cap 200)"]:::system + putLink[("hutch-inbox-email-links
putLink one pending row per url
conditional — replay = no-op")]:::store + meta[("hutch-inbox-email-links
putLinksMeta LAST — extraction-finished barrier,
truncated flag")]:::store + celp["CrawlEmailLinkPreview
one per link, per execution"]:::event + + halert["extract-email-links-truncated-alert queue
+ send-rate alarm → SNS email (hutch, current)"]:::dlq + ialert["inbox-extract-email-links-truncated-alert queue
+ send-rate alarm → SNS email"]:::new + + ere --> hrule --> hq --> hl + ere --> irule --> iq --> il + hq -. "maxReceiveCount" .-> hdlq + iq -. "maxReceiveCount" .-> idlq + hl --> getEmail + il --> getEmail + hl --> raw + il --> raw + hl --> derive + il --> derive + derive -- "each url" --> putLink --> celp + derive --> meta + hl -. "if truncated" .-> halert + il -. "if truncated" .-> ialert +``` + +
+ +--- + +## Crawl flow — one `CrawlEmailLinkPreview`, two rules, converging writes + +Same doubling at the last hop: `CrawlEmailLinkPreview` matches hutch's original +default-named `crawl-email-link-preview-rule` and the new +`inbox-crawl-email-link-preview-rule`. Both workers run the same +`crawl-email-link-preview-handler.ts` over the same SSRF-guarded +`crawlAndFinalizeArticle` the save pipeline uses (`isBlockedIpAddress` on every +connect and redirect hop — fail-closed before any network call), keep only the +metadata, **discard the body**, upload the lead-image thumbnail to the shared +content bucket (served via the save-link content-media CDN), and stamp the +outcome on the link row with `setLinkOutcome` (an `UpdateItem` — replays and +the twin worker's write converge on the same terminal metadata; last write +wins). A dead/blocked/paywalled link is an *expected* `failed` preview and is +ACKed; only a genuine store-write fault or malformed envelope DLQs. + +Neither stack's Lambda is granted the articles/user-articles tables — the +"nothing saved to /queue" invariant stays enforced at the IAM boundary in +**both** deployables. + +![Crawl flow](diagrams/crawl-flow.svg) + +
Mermaid source + +```mermaid +flowchart TD + classDef command fill:#a6d8ff,stroke:#1e6fb8,color:#000; + classDef system fill:#fff2a8,stroke:#a08a00,color:#000; + classDef event fill:#ffb976,stroke:#a85800,color:#000; + classDef policy fill:#d6b8ff,stroke:#6b3fb0,color:#000; + classDef store fill:#b8e8c5,stroke:#2f7a45,color:#000; + classDef queue fill:#e8e8e8,stroke:#666,color:#000; + classDef dlq fill:#f8c8c8,stroke:#a83434,color:#000; + classDef new fill:#ffd24c,stroke:#a0660b,stroke-width:3px,color:#000; + + celp["CrawlEmailLinkPreview
userId, receivedAtMessageId, ordinal, url"]:::event + + hrule["crawl-email-link-preview-rule (hutch, current)"]:::policy + hq["crawl-email-link-preview SQS (hutch, current)"]:::queue + hdlq["crawl-email-link-preview DLQ + alarm"]:::dlq + hl["crawl-email-link-preview Lambda (hutch, current)"]:::system + + irule["inbox-crawl-email-link-preview-rule"]:::new + iq["inbox-crawl-email-link-preview SQS"]:::new + idlq["inbox-crawl-email-link-preview DLQ + alarm"]:::dlq + il["inbox-crawl-email-link-preview Lambda
same handler code, re-homed"]:::new + + ssrf{"validateSaveableUrl +
isBlockedIpAddress on every hop"}:::policy + crawl["crawlAndFinalizeArticle
metadata only — body discarded"]:::system + img[("shared content bucket
lead-image upload, served via
save-link content-media CDN")]:::store + outcome[("hutch-inbox-email-links
setLinkOutcome UpdateItem
crawled / failed — converging, last write wins")]:::store + + celp --> hrule --> hq --> hl + celp --> irule --> iq --> il + hq -. "genuine fault only" .-> hdlq + iq -. "genuine fault only" .-> idlq + hl --> ssrf + il --> ssrf + ssrf -- "unsafe url → failed" --> outcome + ssrf -- "ok" --> crawl + crawl -- "fetched" --> img + crawl -- "fetched → crawled / else failed + reason" --> outcome +``` + +
+ +--- + +## Web flow — `/inbox` re-routed by API Gateway route precedence + +The `inbox` stack attaches its own integration to **hutch's existing API +Gateway** (id + execution ARN via StackReference) with route keys `GET /inbox` +and `ANY /inbox/{proxy+}`. More-specific routes win over `$default`, so +`readplace.com/inbox` is now served by the `inbox-web` Lambda while every other +path still falls through to hutch's SSR Lambda — which **still mounts its own +`/inbox` router** (`server.ts`), now unreachable behind the more-specific +routes and removed in the follow-up cleanup. The pages are the same M2/M3 UI +(received list, addresses, sandboxed email detail, per-link preview cards with +3 s polling), served same-origin so `hutch_sid` session auth keeps working: +the Lambda authenticates each request against the sessions table +(`GetItem`, plus `UpdateItem` when verifying an inbox address flips the +session's email-verified flag), resolves the user via the users `userId-index`, +reads subscription state for the trial banner, and reads sanitized bodies from +the shared content bucket. + +![Web flow](diagrams/web-routes.svg) + +
Mermaid source + +```mermaid +flowchart TD + classDef command fill:#a6d8ff,stroke:#1e6fb8,color:#000; + classDef system fill:#fff2a8,stroke:#a08a00,color:#000; + classDef event fill:#ffb976,stroke:#a85800,color:#000; + classDef policy fill:#d6b8ff,stroke:#6b3fb0,color:#000; + classDef store fill:#b8e8c5,stroke:#2f7a45,color:#000; + classDef queue fill:#e8e8e8,stroke:#666,color:#000; + classDef dlq fill:#f8c8c8,stroke:#a83434,color:#000; + classDef new fill:#ffd24c,stroke:#a0660b,stroke-width:3px,color:#000; + + req(["GET /inbox — reader opens the received list"]):::command + gw["hutch API Gateway (owned by hutch)"]:::system + routes["route keys GET /inbox + ANY /inbox/proxy
attached by the inbox stack —
more-specific beats $default"]:::new + webL["inbox-web Lambda
express app, requireAuth on /inbox"]:::new + ssr["hutch SSR Lambda on $default
/inbox router still mounted — shadowed,
removed in follow-up cleanup"]:::system + + sessions[("sessions table
GetItem each request; UpdateItem on
markSessionEmailVerified")]:::store + users[("users table
Query userId-index")]:::store + subs[("subscription-providers GetItem
trial / access display")]:::store + tables[("hutch-inbox-addresses / -emails / -email-links
lists, detail, link cards + polling")]:::store + content[("shared content bucket
sanitized email body for the iframe")]:::store + banner["changelog banner fragment
same-origin /blog route, fail-open"]:::system + + req --> gw + gw -- "route precedence" --> routes --> webL + gw -. "$default — every other path" .-> ssr + webL --> sessions + webL --> users + webL --> subs + webL --> tables + webL --> content + webL -. "cached, decorative" .-> banner +``` + +
+ +--- + +## Command → System → Event(s) reference + +| Command / trigger | System (handler) | Event(s) emitted | Next | +|---|---|---|---| +| Mail arrives at the mail domain | SES catch-all receipt rule (hutch `InboxMail`) | raw `.eml` to S3, receipt JSON to SNS topic | both receive queues (2 SNS subscriptions) | +| SNS receipt notification | `receive-email` Lambda (hutch, current) **and** `inbox-receive-email` Lambda (new) — same handler | **`EmailReceivedEvent`** per deliverable recipient (so ×2 during overlap); none on audit/ACK branches | both extract rules | +| `EmailReceivedEvent` | `extract-email-links` Lambda (hutch, current) **and** `inbox-extract-email-links` Lambda (new) — same handler | **`CrawlEmailLinkPreview`** one per link, per execution | both crawl rules | +| link cap hit (200) | either extract Lambda | none — `truncated` flag on the meta barrier + message to that stack's dedicated alert queue | stack-local send-rate alarm → operator email | +| `CrawlEmailLinkPreview` | `crawl-email-link-preview` Lambda (hutch, current) **and** `inbox-crawl-email-link-preview` Lambda (new) — same handler | none — `setLinkOutcome` UpdateItem (converging) | — (nothing to /queue, IAM-enforced in both stacks) | +| `GET /inbox`, `ANY /inbox/{proxy+}` | `inbox-web` Lambda (new, via route precedence) | none (read model) | per-card / per-panel polling | +| any other path | hutch SSR Lambda (`$default`) | — | its `/inbox` router is shadowed dead code until cleanup | + +### Double-delivery arithmetic (and why it is safe) + +| Stage | Copies during overlap | Convergence mechanism | +|---|---|---| +| SNS receipt → receive workers | 2 (one per queue subscription) | `putEmail` conditional put — duplicate row is a no-op; body S3 key deterministic (user-scoped) | +| `EmailReceivedEvent` published | 2 per deliverable recipient (each worker re-publishes even on duplicate row) | consumers idempotent | +| extract executions | up to 4 per email (2 publishes × 2 rules) | `putLink` conditional put; `putLinksMeta` rewrites the same value | +| `CrawlEmailLinkPreview` published | up to 4 per link | consumer idempotent | +| crawl executions | up to 8 per link (4 publishes × 2 rules) | `setLinkOutcome` UpdateItem — terminal metadata, last write wins | + +The overlap multiplies compute and origin fetches, never state. Operator +alerting also doubles (each stack has its own DLQ alarms and truncation +alert) — accepted for the window. + +### Wire formats (unchanged by the split — defined in `@packages/hutch-infra-components` `events.ts`) + +``` +EmailReceivedEvent + name: "email-received" + source: "hutch.inbox" + detailType: "EmailReceived" + detail: { userId, receivedAtMessageId, recipientAddress } (all strings) + +CrawlEmailLinkPreview + name: "crawl-email-link-preview" + source: "hutch.inbox" + detailType: "CrawlEmailLinkPreview" + detail: { userId, receivedAtMessageId, ordinal, url } (all strings) +``` + +The event *names* also seed the default EventBridge rule names — which is why +hutch's original rules are `email-received-rule` / `crawl-email-link-preview-rule` +and the inbox stack **must** pass explicit `name` options +(`inbox-extract-email-links`, `inbox-crawl-email-link-preview`) to +`eventBus.subscribe`: same-named `PutRule` is an upsert that would re-target +hutch's live rule rather than add a second subscription. + +### What the follow-up cleanup removes (hutch side) + +- `receive-email`, `extract-email-links`, `crawl-email-link-preview` Lambdas, + their SQS queues/DLQs/alarms, hutch's SNS `TopicSubscription`, the + `email-received-rule` / `crawl-email-link-preview-rule` EventBridge rules, + and the `extract-email-links-truncated-alert` queue + topic + alarm. +- The `/inbox` router mounted in the SSR Lambda (already shadowed by route + precedence). + +Hutch permanently keeps: `InboxMail` (SES receiving + raw bucket + SNS topic), +the three inbox DynamoDB tables, the shared content bucket, the API Gateway, +and the `inboxNotificationTopicArn` / gateway stack outputs the inbox +deployable consumes. diff --git a/.architecture/index.md b/.architecture/index.md index b3edc91c8..e247fbddd 100644 --- a/.architecture/index.md +++ b/.architecture/index.md @@ -42,6 +42,7 @@ event-storming → BPMN element mapping and how to regenerate. | [`fc406ec`](./2026-05-30-fc406ec/) | 2026-05-30 | 2026-05-30 | `claude/dazzling-cray-tkyDl` | feat(hutch,save-link): email savers when their reader view is ready | Reader-ready notification flow — when an article's reader view reaches the successful terminal state, the summary worker emits a new global per-URL fact that fans out through the user/article join table: a SQS-backed fan-out reverse-looks-up every saver via a new url-index, stamps a per-user success timestamp, and dispatches a delayed per-user notify command only for savers who opened the reader while it was loading. The notify worker re-checks per-user gates (not read, not re-saved, took over a minute, viewed-before-ready, verified email, not already sent) against the live row, atomically claims a 6h-per-user email cooldown, sends a reader-ready email, and publishes an email-sent fact. Presence is detected entirely server-side by stamping a viewedAt on the reader open and in-reader polls (never the queue-list card) — no new client JavaScript. Introduces a shared successful-state derivation in the article-state package, a per-message SQS delay option, and three new event/command wire formats. Mermaid sources only — SVG render skipped (sandboxed Chromium unavailable in this environment). | | [`23f75dd`](./2026-06-24-23f75dd/) | 2026-06-24 | 2026-06-24 | `claude/awesome-turing-evfh9d` | feat(hutch,@packages/domain): receive and read forwarded email (M2) | Inbound email receive & read flow — a forwarded newsletter arrives via AWS SES, whose catch-all receipt rule stores the raw message to a forever-retained bucket and notifies SNS; an SNS→SQS-backed receive worker fetches the raw bytes, resolves the recipient forwarding address, parses MIME → HTML (cid inline images rehosted as data: URIs, remote images stripped) through an allowlist sanitizer, writes the safe body to the content store plus a row, and publishes the new past-tense EmailReceivedEvent (no M2 consumer; M3 seam). Oversize, unknown-recipient, disabled-recipient, and unparseable mail each record an auditable row and fail to the DLQ so the existing alarm pages the operator — never silently dropped. The read side turns /inbox into a Gmail-style received list (M1 address management relocates to /inbox/addresses) and an email detail page that renders the full sanitized newsletter inside a sandboxed, script-less iframe under a default-src 'none' CSP. Four pre-rendered SVG diagrams. | | [`cd26b26`](./2026-06-28-cd26b26/) | 2026-06-28 | 2026-06-28 | `claude/email-link-previews-bs64f0` | feat(hutch,save-link): wire the inbox link-preview pipeline end to end (M3) | Inbox link-preview flow — M2's consumer-less EmailReceivedEvent now drives two new SQS-backed consumers. extract-email-links re-derives the body from the immutable raw .eml (re-parse → re-sanitize via a shared deriveSanitizedBody), extracts links, writes one pending row per link to a new hutch-inbox-email-links table, and fans out one CrawlEmailLinkPreview command per link (mirroring how /queue fans each import URL into its own SaveLinkCommand); a per-email soft cap (200) bounds the fan-out and a truncated email still ships the first N previews while writing a truncated meta item and SendMessage-ing its own DLQ to page the operator (★14). crawl-email-link-preview consumes one command per link, runs the same SSRF-guarded crawlAndFinalizeArticle the save pipeline uses (★16), keeps only the metadata, discards the body, and stamps crawled/failed onto the link row — nothing is written to the articles/user-articles tables, enforced at the IAM boundary. The Articles tab replaces its M2 placeholder with one preview card per link, each pending card self-polling /inbox/:id/links/:ordinal/card every 3s (etag + 304, shared MAX_POLLS) to terminal exactly like /queue; the list and detail header gain an "N links" badge derived from the same per-email partition Query (no parent-row denormalisation). The lead-image upload reads the save-link content-media CDN base URL cross-stack. Mermaid sources only — SVG render skipped. | +| [`bec433e1`](./2026-07-10-bec433e1/) | 2026-07-10 | 2026-07-10 | `inbox-split` | feat(hutch): export the inbox notification topic ARN for the inbox deployable | Inbox deployable split — the inbound-email pipeline mid-extraction into its own deployable, captured during the deliberate double-delivery overlap window. A new inbox stack stands up its own copies of the three consumers (SNS→SQS receive worker, EmailReceivedEvent→link extractor, CrawlEmailLinkPreview→preview crawler) beside the still-live originals: the SES receipt topic carries two SQS subscriptions and each event matches two EventBridge rules, so every stage runs twice per mail — with distinct inbox-prefixed rule/queue/Lambda names throughout because PutRule upserts and a same-named resource would steal the live one. Replay-idempotent handlers (conditional puts, always-last meta barrier, terminal last-write-wins UpdateItem) make the overlap cost duplicate compute, never duplicate state. SES receiving (domain identity, catch-all receipt rule set, immutable raw bucket, SNS topic — its auto-named ARN newly exported cross-stack) and the three inbox tables stay with the original stack; the /inbox pages re-route to a new web Lambda attached to the same API Gateway by route precedence, shadowing the still-mounted original router until a follow-up cleanup removes the duplicated consumers. Six pre-rendered SVG diagrams. | --- diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10a930e7c..952c6764c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -360,6 +360,9 @@ importers: '@packages/hutch-storage-client': specifier: workspace:* version: link:../../src/packages/hutch-storage-client + '@packages/inbox-store': + specifier: workspace:* + version: link:../../src/packages/inbox-store '@packages/onboarding-extension-signal': specifier: workspace:* version: link:../../src/packages/onboarding-extension-signal @@ -515,6 +518,136 @@ importers: specifier: 6.0.3 version: 6.0.3 + projects/inbox: + dependencies: + '@aws-sdk/client-s3': + specifier: 3.1057.0 + version: 3.1057.0 + '@aws-sdk/client-sqs': + specifier: 3.1057.0 + version: 3.1057.0 + '@packages/article-parser': + specifier: workspace:* + version: link:../../src/packages/article-parser + '@packages/article-resource-unique-id': + specifier: workspace:* + version: link:../../src/packages/article-resource-unique-id + '@packages/article-store': + specifier: workspace:* + version: link:../../src/packages/article-store + '@packages/crawl-article': + specifier: workspace:* + version: link:../../src/packages/crawl-article + '@packages/domain': + specifier: workspace:* + version: link:../../src/packages/domain + '@packages/finalize-article': + specifier: workspace:* + version: link:../../src/packages/finalize-article + '@packages/hutch-infra-components': + specifier: workspace:* + version: link:../../src/packages/hutch-infra-components + '@packages/hutch-logger': + specifier: workspace:* + version: link:../../src/packages/hutch-logger + '@packages/hutch-storage-client': + specifier: workspace:* + version: link:../../src/packages/hutch-storage-client + '@packages/inbox-store': + specifier: workspace:* + version: link:../../src/packages/inbox-store + '@packages/provider-contracts': + specifier: workspace:* + version: link:../../src/packages/provider-contracts + '@packages/require-env': + specifier: workspace:* + version: link:../../src/packages/require-env + '@packages/subscription-access': + specifier: workspace:* + version: link:../../src/packages/subscription-access + '@packages/web-session': + specifier: workspace:* + version: link:../../src/packages/web-session + '@packages/web-shell': + specifier: workspace:* + version: link:../../src/packages/web-shell + compression: + specifier: 1.8.1 + version: 1.8.1 + cookie-parser: + specifier: 1.4.7 + version: 1.4.7 + express: + specifier: 5.2.1 + version: 5.2.1 + helmet: + specifier: 8.2.0 + version: 8.2.0 + serverless-http: + specifier: 4.0.0 + version: 4.0.0 + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@packages/test-fixtures': + specifier: workspace:* + version: link:../../src/packages/test-fixtures + '@packages/web-test-harness': + specifier: workspace:* + version: link:../../src/packages/web-test-harness + '@pulumi/aws': + specifier: 7.32.0 + version: 7.32.0(ts-node@10.9.2(@types/node@22.19.19)(typescript@6.0.3))(typescript@6.0.3) + '@pulumi/pulumi': + specifier: 3.244.0 + version: 3.244.0(ts-node@10.9.2(@types/node@22.19.19)(typescript@6.0.3))(typescript@6.0.3) + '@types/aws-lambda': + specifier: 8.10.161 + version: 8.10.161 + '@types/compression': + specifier: 1.8.1 + version: 1.8.1 + '@types/cookie-parser': + specifier: 1.4.10 + version: 1.4.10(@types/express@5.0.6) + '@types/express': + specifier: 5.0.6 + version: 5.0.6 + '@types/jest': + specifier: 30.0.0 + version: 30.0.0 + '@types/jsdom': + specifier: 21.1.7 + version: 21.1.7 + '@types/node': + specifier: 22.19.19 + version: 22.19.19 + '@types/supertest': + specifier: 7.2.0 + version: 7.2.0 + c8: + specifier: 11.0.0 + version: 11.0.0 + concurrently: + specifier: 10.0.0 + version: 10.0.0 + jest: + specifier: 30.4.2 + version: 30.4.2(@types/node@22.19.19)(node-notifier@10.0.1)(ts-node@10.9.2(@types/node@22.19.19)(typescript@6.0.3)) + jsdom: + specifier: 26.1.0 + version: 26.1.0 + supertest: + specifier: 7.2.2 + version: 7.2.2 + tsx: + specifier: 4.22.3 + version: 4.22.3 + typescript: + specifier: 6.0.3 + version: 6.0.3 + projects/platform: dependencies: '@packages/hutch-infra-components': @@ -871,6 +1004,9 @@ importers: src/packages/article-store: dependencies: + '@aws-sdk/client-s3': + specifier: 3.1057.0 + version: 3.1057.0 '@packages/article-resource-unique-id': specifier: workspace:* version: link:../article-resource-unique-id @@ -1193,6 +1329,37 @@ importers: specifier: 6.0.3 version: 6.0.3 + src/packages/inbox-store: + dependencies: + '@aws-sdk/client-s3': + specifier: 3.1057.0 + version: 3.1057.0 + '@packages/domain': + specifier: workspace:* + version: link:../domain + '@packages/hutch-storage-client': + specifier: workspace:* + version: link:../hutch-storage-client + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@types/jest': + specifier: 30.0.0 + version: 30.0.0 + c8: + specifier: 11.0.0 + version: 11.0.0 + concurrently: + specifier: 10.0.0 + version: 10.0.0 + jest: + specifier: 30.4.2 + version: 30.4.2(@types/node@22.19.19)(node-notifier@10.0.1)(ts-node@10.9.2(@types/node@22.19.19)(typescript@6.0.3)) + typescript: + specifier: 6.0.3 + version: 6.0.3 + src/packages/onboarding-extension-signal: devDependencies: concurrently: @@ -1330,6 +1497,9 @@ importers: specifier: 4.4.3 version: 4.4.3 devDependencies: + '@packages/test-fixtures': + specifier: workspace:* + version: link:../test-fixtures '@types/jest': specifier: 30.0.0 version: 30.0.0 @@ -1540,6 +1710,9 @@ importers: node-html-markdown: specifier: 2.0.0 version: 2.0.0 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@types/jest': specifier: 30.0.0 @@ -2618,25 +2791,21 @@ packages: resolution: {integrity: sha512-QLnkJl3HkHsPfpLiNiAiMfpfAeFpic0U1diAxF8RqChOkCpQ7ulvyBVgE1UrQxvhd+gFQ3ed5RNDxtCRw8nTiw==} cpu: [arm64] os: [linux] - libc: [glibc] '@nx/nx-linux-arm64-musl@22.7.5': resolution: {integrity: sha512-cEP6KmwBgnb38+jTTaibWCjwXcHmigqhTfy0tN1be7WZr6bHxbqNLsXqKRN70PSNA3HouZcxw1cdRL8tqbPBBA==} cpu: [arm64] os: [linux] - libc: [musl] '@nx/nx-linux-x64-gnu@22.7.5': resolution: {integrity: sha512-tbaX1tZCSpGifDNBfDdEZAMxVF3Yg4bhFP/bm1needc0diqb+Zflc0u5tM5/6BWDMITQDwenJVsNiQ8ZdtJURA==} cpu: [x64] os: [linux] - libc: [glibc] '@nx/nx-linux-x64-musl@22.7.5': resolution: {integrity: sha512-H0M7csOZIgPT822LqjxSXzf4MXRND15vIkAQe3F3Jlr3Si8LC3tzbL52aVcRfgb8MF/xOB5U47mSwxWt1M2bPQ==} cpu: [x64] os: [linux] - libc: [musl] '@nx/nx-win32-arm64-msvc@22.7.5': resolution: {integrity: sha512-JTcZch9YAnDL1gbhqePz3DZ4x7iYemLn1yJzrjbbXAmXju2eiiJiZvJJHbV06+SP9HKXDT8RjTKuAWTdVxnHug==} diff --git a/projects/hutch/package.json b/projects/hutch/package.json index 67e9fe907..2194b598e 100644 --- a/projects/hutch/package.json +++ b/projects/hutch/package.json @@ -1,7 +1,7 @@ { "name": "hutch", "version": "1.0.0", - "description": "Readplace — read-it-later web application", + "description": "Readplace \u2014 read-it-later web application", "main": "src/infra/index.ts", "exports": { ".": "./src/infra/index.ts", @@ -60,6 +60,7 @@ "@packages/hutch-infra-components": "workspace:*", "@packages/hutch-logger": "workspace:*", "@packages/hutch-storage-client": "workspace:*", + "@packages/inbox-store": "workspace:*", "@packages/onboarding-extension-signal": "workspace:*", "@packages/provider-contracts": "workspace:*", "@packages/refresh-article-content": "workspace:*", diff --git a/projects/hutch/src/e2e/verify-banner-nav-visual.e2e-local.ts b/projects/hutch/src/e2e/verify-banner-nav-visual.e2e-local.ts index e5422e96b..a9c772f4e 100644 --- a/projects/hutch/src/e2e/verify-banner-nav-visual.e2e-local.ts +++ b/projects/hutch/src/e2e/verify-banner-nav-visual.e2e-local.ts @@ -41,7 +41,7 @@ async function seedArticle(page: Page): Promise { // Sign up a fresh user through the real form. New accounts are unverified, so // the shell renders the "N days left" countdown banner on every page — and a -// just-registered user is deterministically 7 days out (verification-deadline.ts). +// just-registered user is deterministically 7 days out. async function signUpUnverified(page: Page, email: string): Promise { await page.goto(`${BASE_URL}/signup`, { waitUntil: "domcontentloaded" }); await page.locator("#email").fill(email); diff --git a/projects/hutch/src/infra/index.ts b/projects/hutch/src/infra/index.ts index 32b1d6823..8f19ebf82 100644 --- a/projects/hutch/src/infra/index.ts +++ b/projects/hutch/src/infra/index.ts @@ -1563,4 +1563,8 @@ export const sessionsTableArn = storage.sessionsTable.arn; export const exportUserDataQueueUrl = exportUserDataQueue.queueUrl; export const exportUserDataDlqUrl = exportUserDataQueue.dlqUrl; export const userExportBucketOutputName = userExportBucket.bucket; +// Consumed by the inbox deployable via StackReference: the SES receipt topic +// is auto-named, so its ARN is genuinely deploy-time. hutch must deploy first +// so this output exists before inbox's first deploy. +export const inboxNotificationTopicArn = inboxMail.notificationTopicArn; export const _dependencies = [gateway.defaultRoute]; diff --git a/projects/hutch/src/runtime/app.ts b/projects/hutch/src/runtime/app.ts index 565aab8a8..89bfd90ee 100644 --- a/projects/hutch/src/runtime/app.ts +++ b/projects/hutch/src/runtime/app.ts @@ -50,7 +50,7 @@ import { initInMemoryArticleCrawl } from "@packages/test-fixtures/providers/arti import { initInMemoryGeneratedSummary } from "@packages/test-fixtures/providers/article-summary"; import { S3Client } from "@aws-sdk/client-s3"; import { SchedulerClient } from "@aws-sdk/client-scheduler"; -import { initS3ReadContent } from "./providers/article-store/s3-read-content"; +import { initS3ReadContent } from "@packages/article-store"; import { initStripeSubscriptions } from "./providers/stripe-subscriptions/stripe-subscriptions"; import { initStripePaymentMethods } from "./providers/stripe-payment-methods/stripe-payment-methods"; import { initInMemoryPaymentMethods } from "@packages/test-fixtures/providers/payment-methods"; @@ -93,9 +93,6 @@ import { initInMemoryImportSession } from "@packages/test-fixtures/providers/imp import { initDynamoDbImportSession } from "./providers/import-session/dynamodb-import-session"; import { initInMemoryInboxAddress } from "@packages/test-fixtures/providers/inbox-address"; import { initInMemoryInboxEmail, initInMemoryInboxEmailLink } from "@packages/test-fixtures/providers/inbox-email"; -import { initDynamoDbInboxAddress } from "./providers/inbox-address/dynamodb-inbox-address"; -import { initDynamoDbInboxEmail } from "./providers/inbox-email/dynamodb-inbox-email"; -import { initDynamoDbInboxEmailLink } from "./providers/inbox-email/dynamodb-inbox-email-link"; import { initExchangeGoogleCode } from "./providers/google-auth/google-token"; import { initExchangeAppleCode } from "./providers/apple-auth/apple-token"; import { initCreateAppleClientSecret } from "./providers/apple-auth/apple-client-secret"; @@ -105,7 +102,7 @@ import { initStripeCheckout } from "./providers/stripe-checkout/stripe-checkout" import { initInMemoryPendingSignup } from "@packages/test-fixtures/providers/pending-signup"; import { initDynamoDbPendingSignup } from "./providers/pending-signup/dynamodb-pending-signup"; import { initInMemorySubscriptionProviders } from "@packages/test-fixtures/providers/subscription-providers"; -import { initDynamoDbSubscriptionRead } from "./providers/subscription-providers/dynamodb-subscription-read"; +import { initDynamoDbSubscriptionRead } from "@packages/subscription-access"; import { initDynamoDbSubscriptionWrites } from "./providers/subscription-providers/dynamodb-subscription-writes"; import { HutchLogger, consoleLogger } from "@packages/hutch-logger"; import { initLogParseError, type ParseErrorEvent } from "@packages/hutch-infra-components"; @@ -119,6 +116,7 @@ import { httpErrorMessageMapping } from "./web/pages/queue/queue.error"; import { initFoundingAllocation } from "./web/shared/founding-progress/founding-allocation"; import { initCachedUserCount } from "./web/auth/cached-user-count"; import { getEnv, requireEnv } from "@packages/require-env"; +import { initDynamoDbInboxAddress, initDynamoDbInboxEmail, initDynamoDbInboxEmailLink } from "@packages/inbox-store"; /** * Hutch SSR does not run PDF extraction in-process — the diff --git a/projects/hutch/src/runtime/crawl-email-link-preview.main.ts b/projects/hutch/src/runtime/crawl-email-link-preview.main.ts index c41c061d0..2a6941adf 100644 --- a/projects/hutch/src/runtime/crawl-email-link-preview.main.ts +++ b/projects/hutch/src/runtime/crawl-email-link-preview.main.ts @@ -19,7 +19,7 @@ import { createDynamoDocumentClient } from "@packages/hutch-storage-client"; import { requireEnv } from "@packages/require-env"; import { initCrawlEmailLinkPreviewHandler } from "./domain/inbox/crawl-email-link-preview-handler"; import { initS3PutImageObject } from "./providers/article-store/s3-put-image-object"; -import { initDynamoDbInboxEmailLink } from "./providers/inbox-email/dynamodb-inbox-email-link"; +import { initDynamoDbInboxEmailLink } from "@packages/inbox-store"; const inboxEmailLinksTable = requireEnv("DYNAMODB_INBOX_EMAIL_LINKS_TABLE"); const contentBucketName = requireEnv("CONTENT_BUCKET_NAME"); diff --git a/projects/hutch/src/runtime/delete-account.main.ts b/projects/hutch/src/runtime/delete-account.main.ts index f50646ced..9e4469e99 100644 --- a/projects/hutch/src/runtime/delete-account.main.ts +++ b/projects/hutch/src/runtime/delete-account.main.ts @@ -15,16 +15,13 @@ import { initIosOnboardingSignal } from "./providers/ios-onboarding-signal/dynam import { initDynamoDbSubscriptionProviders } from "./providers/subscription-providers/dynamodb-subscription-providers"; import { initStripeSubscriptions } from "./providers/stripe-subscriptions/stripe-subscriptions"; import { initAwsTrialScheduler } from "./providers/trial-scheduler/aws-trial-scheduler"; -import { initDynamoDbInboxEmail } from "./providers/inbox-email/dynamodb-inbox-email"; -import { initDynamoDbInboxEmailLink } from "./providers/inbox-email/dynamodb-inbox-email-link"; -import { initS3DeleteObjects } from "./providers/inbox-email/s3-delete-objects"; -import { initDynamoDbInboxAddress } from "./providers/inbox-address/dynamodb-inbox-address"; import { initS3UserDataExport } from "./providers/user-data-export/s3-user-data-export"; import { initDynamoDbPasswordReset } from "./providers/password-reset/dynamodb-password-reset"; import { initDynamoDbEmailVerification } from "./providers/email-verification/dynamodb-email-verification"; import { initDynamoDbPendingSignup } from "./providers/pending-signup/dynamodb-pending-signup"; import { initRevokeExternalIdpTokens } from "./delete-account/revoke-external-idp-tokens"; import { initDeleteAccountHandler } from "./delete-account/delete-account-handler"; +import { initDynamoDbInboxEmail, initDynamoDbInboxEmailLink, initDynamoDbInboxAddress, initS3DeleteObjects } from "@packages/inbox-store"; const logger = HutchLogger.from(consoleLogger); const now = () => new Date(); diff --git a/projects/hutch/src/runtime/extract-email-links.main.ts b/projects/hutch/src/runtime/extract-email-links.main.ts index c511077ec..9058fd6e1 100644 --- a/projects/hutch/src/runtime/extract-email-links.main.ts +++ b/projects/hutch/src/runtime/extract-email-links.main.ts @@ -8,9 +8,7 @@ import { HutchLogger, consoleLogger } from "@packages/hutch-logger"; import { createDynamoDocumentClient } from "@packages/hutch-storage-client"; import { requireEnv } from "@packages/require-env"; import { initExtractEmailLinksHandler } from "./domain/inbox/extract-email-links-handler"; -import { initDynamoDbInboxEmail } from "./providers/inbox-email/dynamodb-inbox-email"; -import { initDynamoDbInboxEmailLink } from "./providers/inbox-email/dynamodb-inbox-email-link"; -import { initS3ReadRawEmail } from "./providers/inbox-email/s3-read-raw-email"; +import { initDynamoDbInboxEmail, initDynamoDbInboxEmailLink, initS3ReadRawEmail } from "@packages/inbox-store"; const inboxEmailsTable = requireEnv("DYNAMODB_INBOX_EMAILS_TABLE"); const inboxEmailLinksTable = requireEnv("DYNAMODB_INBOX_EMAIL_LINKS_TABLE"); diff --git a/projects/hutch/src/runtime/providers/email-verification/dynamodb-email-verification.ts b/projects/hutch/src/runtime/providers/email-verification/dynamodb-email-verification.ts index 6ca1fc693..63457a17d 100644 --- a/projects/hutch/src/runtime/providers/email-verification/dynamodb-email-verification.ts +++ b/projects/hutch/src/runtime/providers/email-verification/dynamodb-email-verification.ts @@ -12,7 +12,7 @@ import type { VerifyEmailToken, } from "@packages/provider-contracts/email-verification"; import { VerificationTokenSchema } from "@packages/provider-contracts/email-verification"; -import { VERIFICATION_WINDOW_MS } from "../../domain/access/verification-deadline"; +import { VERIFICATION_WINDOW_MS } from "@packages/domain/user"; // Keep the single mailed link valid for the whole lockout window, so it never // expires before the account does. diff --git a/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.ts b/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.ts index e7cb05dda..3cbe38c94 100644 --- a/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.ts +++ b/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.ts @@ -1,5 +1,5 @@ import type { DynamoDBDocumentClient } from "@packages/hutch-storage-client"; -import { initDynamoDbSubscriptionRead } from "./dynamodb-subscription-read"; +import { initDynamoDbSubscriptionRead } from "@packages/subscription-access"; import { initDynamoDbSubscriptionWrites } from "./dynamodb-subscription-writes"; /** The full subscription table (read + write), for entry points that need both diff --git a/projects/hutch/src/runtime/receive-email.main.ts b/projects/hutch/src/runtime/receive-email.main.ts index a16925f88..ee37b7ad0 100644 --- a/projects/hutch/src/runtime/receive-email.main.ts +++ b/projects/hutch/src/runtime/receive-email.main.ts @@ -6,10 +6,7 @@ import { createDynamoDocumentClient } from "@packages/hutch-storage-client"; import { requireEnv } from "@packages/require-env"; import { initReceiveEmailHandler } from "./domain/inbox/receive-email-handler"; import { initStoreEmailBody } from "./domain/inbox/store-email-body"; -import { initDynamoDbInboxAddress } from "./providers/inbox-address/dynamodb-inbox-address"; -import { initDynamoDbInboxEmail } from "./providers/inbox-email/dynamodb-inbox-email"; -import { initS3ReadRawEmail } from "./providers/inbox-email/s3-read-raw-email"; -import { initS3WriteEmailContent } from "./providers/inbox-email/s3-write-email-content"; +import { initDynamoDbInboxAddress, initDynamoDbInboxEmail, initS3ReadRawEmail, initS3WriteEmailContent } from "@packages/inbox-store"; const inboxEmailsTable = requireEnv("DYNAMODB_INBOX_EMAILS_TABLE"); const inboxAddressesTable = requireEnv("DYNAMODB_INBOX_ADDRESSES_TABLE"); diff --git a/projects/hutch/src/runtime/send-user-digest.main.ts b/projects/hutch/src/runtime/send-user-digest.main.ts index bbbe7b390..adb3ce0d0 100644 --- a/projects/hutch/src/runtime/send-user-digest.main.ts +++ b/projects/hutch/src/runtime/send-user-digest.main.ts @@ -8,7 +8,7 @@ import { initDynamoDbArticleStore } from "./providers/article-store/dynamodb-art import { initDynamoDbAuth } from "./providers/auth/dynamodb-auth"; import { initDynamoDbReaderReadyState } from "./providers/reader-ready-state/dynamodb-reader-ready-state"; import { initDynamoDbDigestQueue } from "./providers/digest-queue/dynamodb-digest-queue"; -import { initS3ReadContent } from "./providers/article-store/s3-read-content"; +import { initS3ReadContent } from "@packages/article-store"; import { initResendEmail } from "./providers/email/resend-email"; import { initSendUserDigestHandler } from "./send-user-digest/send-user-digest-handler"; import { requireEnv } from "@packages/require-env"; diff --git a/projects/hutch/src/runtime/server.ts b/projects/hutch/src/runtime/server.ts index c91b5bbce..5ab0409b6 100644 --- a/projects/hutch/src/runtime/server.ts +++ b/projects/hutch/src/runtime/server.ts @@ -181,7 +181,7 @@ import { wantsSiren } from "./web/content-negotiation"; import { CONTENT_SIGNAL_VALUE, contentSignalMiddleware } from "./web/content-signal.middleware"; import { linkHeaderMiddleware } from "./web/link-header.middleware"; import { AGENT_SCOPES_SUPPORTED, buildAgentAuthMetadata, renderAuthMarkdown } from "./web/agent-auth"; -import { QuerystringFeatureToggle } from "./web/feature-toggle"; +import { QuerystringFeatureToggle } from "@packages/web-shell"; import { HomePage } from "./web/pages/home"; import { McpConnectPage } from "./web/pages/mcp"; import { PrivacyPage } from "./web/pages/privacy"; @@ -195,7 +195,7 @@ import { initLandingRoutes } from "./web/pages/landing"; import { HOMEPAGE_SPLIT } from "./web/experiments/homepage-split"; import { detectInstallBrowser } from "./web/onboarding/extension-install"; import { NotFoundPage } from "./web/pages/not-found"; -import { initGetEffectiveAccess } from "./domain/access/effective-access"; +import { initGetEffectiveAccess } from "@packages/subscription-access"; import { initRequireWriteAccess } from "./web/middleware/require-write-access.middleware"; import { initResolveVerificationStatus } from "./web/middleware/resolve-verification-status.middleware"; import { requireNotLocked } from "./web/middleware/require-not-locked.middleware"; diff --git a/projects/hutch/src/runtime/web/banner-state.test.ts b/projects/hutch/src/runtime/web/banner-state.test.ts index 1e3ffa121..e00793edc 100644 --- a/projects/hutch/src/runtime/web/banner-state.test.ts +++ b/projects/hutch/src/runtime/web/banner-state.test.ts @@ -3,7 +3,7 @@ import { type ChangelogBanner, isChangelogVersion } from "@packages/web-shell"; import { UserIdSchema } from "@packages/domain/user"; import { initBuildBannerState } from "./banner-state"; import type { GetChangelogBanner } from "./changelog-banner-source"; -import type { EffectiveAccess } from "../domain/access/effective-access"; +import type { EffectiveAccess } from "@packages/subscription-access"; const USER_ID = UserIdSchema.parse("user-1"); const ONE_DAY_MS = 86_400_000; diff --git a/projects/hutch/src/runtime/web/banner-state.ts b/projects/hutch/src/runtime/web/banner-state.ts index 79b93164e..915339620 100644 --- a/projects/hutch/src/runtime/web/banner-state.ts +++ b/projects/hutch/src/runtime/web/banner-state.ts @@ -4,7 +4,7 @@ import type { BannerState, BannerStateSource } from "@packages/web-shell"; import type { EffectiveAccess, GetEffectiveAccess, -} from "../domain/access/effective-access"; +} from "@packages/subscription-access"; import type { GetChangelogBanner } from "./changelog-banner-source"; import { toTrialDisplay } from "./trial-display"; diff --git a/projects/hutch/src/runtime/web/feature-toggle.ts b/projects/hutch/src/runtime/web/feature-toggle.ts deleted file mode 100644 index 14f7a6dfa..000000000 --- a/projects/hutch/src/runtime/web/feature-toggle.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Request } from "express"; - -export class QuerystringFeatureToggle { - isEnabled(req: Request, feature: string): boolean { - return req.query.feature === feature; - } -} diff --git a/projects/hutch/src/runtime/web/mcp/save-access.ts b/projects/hutch/src/runtime/web/mcp/save-access.ts index 851224a20..517ebab42 100644 --- a/projects/hutch/src/runtime/web/mcp/save-access.ts +++ b/projects/hutch/src/runtime/web/mcp/save-access.ts @@ -1,7 +1,7 @@ import type { UserId } from "@packages/domain/user"; import type { FindUserById } from "@packages/provider-contracts/auth"; import { VERIFICATION_CONTACT_EMAIL } from "@packages/web-shell"; -import { computeVerificationStatus } from "../../domain/access/verification-deadline"; +import { computeVerificationStatus } from "@packages/domain/user"; /** * Whether an authenticated agent's account is unlocked enough to save a new diff --git a/projects/hutch/src/runtime/web/mcp/tool-access.test.ts b/projects/hutch/src/runtime/web/mcp/tool-access.test.ts index 964b1e353..b243bc48c 100644 --- a/projects/hutch/src/runtime/web/mcp/tool-access.test.ts +++ b/projects/hutch/src/runtime/web/mcp/tool-access.test.ts @@ -4,7 +4,7 @@ import type { SubscriptionRecord, SubscriptionStatus, } from "@packages/provider-contracts/subscription-providers"; -import { initGetEffectiveAccess } from "../../domain/access/effective-access"; +import { initGetEffectiveAccess } from "@packages/subscription-access"; import { initResolveToolAccess } from "./tool-access"; const userId = authenticatedUserIdFrom("00000000000000000000000000000001"); diff --git a/projects/hutch/src/runtime/web/mcp/tool-access.ts b/projects/hutch/src/runtime/web/mcp/tool-access.ts index 351354b11..06f40ed80 100644 --- a/projects/hutch/src/runtime/web/mcp/tool-access.ts +++ b/projects/hutch/src/runtime/web/mcp/tool-access.ts @@ -4,7 +4,7 @@ import { deriveTrialEscalation, formatTrialRemaining, } from "@packages/web-shell"; -import type { GetEffectiveAccess } from "../../domain/access/effective-access"; +import type { GetEffectiveAccess } from "@packages/subscription-access"; /** * Whether the MCP surface is open to an authenticated caller, and how diff --git a/projects/hutch/src/runtime/web/middleware/resolve-verification-status.middleware.ts b/projects/hutch/src/runtime/web/middleware/resolve-verification-status.middleware.ts index f1a529b6e..57479ac85 100644 --- a/projects/hutch/src/runtime/web/middleware/resolve-verification-status.middleware.ts +++ b/projects/hutch/src/runtime/web/middleware/resolve-verification-status.middleware.ts @@ -3,7 +3,7 @@ import type { FindUserById, MarkSessionEmailVerified, } from "@packages/provider-contracts/auth"; -import { computeVerificationStatus } from "../../domain/access/verification-deadline"; +import { computeVerificationStatus } from "@packages/domain/user"; import { SESSION_COOKIE_NAME } from "@packages/web-session"; /** diff --git a/projects/hutch/src/runtime/web/pages/account/account.page.ts b/projects/hutch/src/runtime/web/pages/account/account.page.ts index bd492c504..d122bed37 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.page.ts +++ b/projects/hutch/src/runtime/web/pages/account/account.page.ts @@ -42,7 +42,7 @@ import { Base } from "../../base.component"; import type { BuildBannerState } from "../../banner-state"; import { HxRedirectPage } from "../../hx-redirect-page"; import { sendComponent } from "@packages/web-shell"; -import type { EffectiveAccess, GetEffectiveAccess } from "../../../domain/access/effective-access"; +import type { EffectiveAccess, GetEffectiveAccess } from "@packages/subscription-access"; import { AccountPage } from "./account.component"; import { type CardError, diff --git a/projects/hutch/src/runtime/web/pages/account/account.view-model.test.ts b/projects/hutch/src/runtime/web/pages/account/account.view-model.test.ts index a8a9a2c75..14b38467e 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.view-model.test.ts +++ b/projects/hutch/src/runtime/web/pages/account/account.view-model.test.ts @@ -7,7 +7,7 @@ import { parseAccountQuery, withoutCommerce, } from "./account.view-model"; -import type { EffectiveAccess } from "../../../domain/access/effective-access"; +import type { EffectiveAccess } from "@packages/subscription-access"; function savedCard(id: string, isPrimary: boolean, overrides?: Partial): SavedCard { return { diff --git a/projects/hutch/src/runtime/web/pages/account/account.view-model.ts b/projects/hutch/src/runtime/web/pages/account/account.view-model.ts index 3bf220452..11687e615 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.view-model.ts +++ b/projects/hutch/src/runtime/web/pages/account/account.view-model.ts @@ -1,7 +1,7 @@ import { decomposeTimeLeft } from "@packages/time-left"; import { escapeRegExp } from "@packages/escape-regexp"; import type { SavedCard } from "@packages/provider-contracts/payment-methods"; -import type { EffectiveAccess } from "../../../domain/access/effective-access"; +import type { EffectiveAccess } from "@packages/subscription-access"; import { type LocalTime, SUBSCRIBE_CTA_LABEL, toAbsoluteDate, withInternalTracking } from "@packages/web-shell"; import { ACCOUNT_CANCEL_URL, diff --git a/projects/hutch/src/runtime/web/pages/inbox/inbox.page.ts b/projects/hutch/src/runtime/web/pages/inbox/inbox.page.ts index 7a9e3a1d9..79fe14ee2 100644 --- a/projects/hutch/src/runtime/web/pages/inbox/inbox.page.ts +++ b/projects/hutch/src/runtime/web/pages/inbox/inbox.page.ts @@ -21,9 +21,9 @@ import type { ContentProvider } from "@packages/provider-contracts/article-store import { emailContentResourceId } from "../../../domain/inbox/email-content-id"; import { Base } from "../../base.component"; import type { BuildBannerState } from "../../banner-state"; -import type { QuerystringFeatureToggle } from "../../feature-toggle"; -import { MAX_POLLS } from "../../shared/article-reader/article-reader"; -import { etagMatches } from "../queue/queue-card/queue-card.etag"; +import type { QuerystringFeatureToggle } from "@packages/web-shell"; +import { MAX_POLLS } from "@packages/web-shell"; +import { etagMatches } from "@packages/web-shell"; import { renderInboxArticleCard } from "./inbox-article-card.component"; import { renderInboxArticlesPanel } from "./inbox-articles-panel.component"; import { InboxEmailDetailPage } from "./inbox-email-detail.component"; @@ -38,7 +38,7 @@ import { import { type InboxEmailLinkSummary, toInboxEmailsViewModel } from "./inbox-emails.viewmodel"; import { computeInboxLinkCardEtag } from "./inbox-link-card.etag"; import { toInboxLinkCardViewModel } from "./inbox-link-card.viewmodel"; -import { parsePollParam } from "../../shared/article-reader/poll-param"; +import { parsePollParam } from "@packages/web-shell"; import { InboxPage } from "./inbox.component"; interface InboxDependencies { diff --git a/projects/hutch/src/runtime/web/pages/queue/queue-card/queue-card.etag.test.ts b/projects/hutch/src/runtime/web/pages/queue/queue-card/queue-card.etag.test.ts index a2c637119..fd5259f1a 100644 --- a/projects/hutch/src/runtime/web/pages/queue/queue-card/queue-card.etag.test.ts +++ b/projects/hutch/src/runtime/web/pages/queue/queue-card/queue-card.etag.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import type { Minutes, SavedArticle } from "@packages/domain/article"; import { ReaderArticleHashId } from "@packages/domain/article"; import type { UserId } from "@packages/domain/user"; -import { computeQueueCardEtag, etagMatches } from "./queue-card.etag"; +import { computeQueueCardEtag } from "./queue-card.etag"; const ARTICLE_URL = "https://example.com/post"; @@ -102,21 +102,3 @@ describe("computeQueueCardEtag", () => { expect(unread).not.toBe(read); }); }); - -describe("etagMatches", () => { - it("returns false for an undefined If-None-Match header", () => { - expect(etagMatches(undefined, 'W/"abc"')).toBe(false); - }); - - it("matches an exact single ETag", () => { - expect(etagMatches('W/"abc"', 'W/"abc"')).toBe(true); - }); - - it("matches when the header carries multiple ETags", () => { - expect(etagMatches('W/"abc", W/"def"', 'W/"def"')).toBe(true); - }); - - it("does not match when no entry equals the ETag", () => { - expect(etagMatches('W/"abc"', 'W/"xyz"')).toBe(false); - }); -}); diff --git a/projects/hutch/src/runtime/web/pages/queue/queue-card/queue-card.etag.ts b/projects/hutch/src/runtime/web/pages/queue/queue-card/queue-card.etag.ts index 670e657f2..b1ea682f2 100644 --- a/projects/hutch/src/runtime/web/pages/queue/queue-card/queue-card.etag.ts +++ b/projects/hutch/src/runtime/web/pages/queue/queue-card/queue-card.etag.ts @@ -46,14 +46,3 @@ export function computeQueueCardEtag(input: QueueCardEtagInput): string { ]); return `W/"${article.id.value}:${article.status}:${crawlStatus}:${summaryStatus}:${article.metadata.wordCount}:${contentHash}:${hashFields([crawlReason, summaryReason])}"`; } - -export function etagMatches( - ifNoneMatchHeader: string | undefined, - etag: string, -): boolean { - if (!ifNoneMatchHeader) return false; - return ifNoneMatchHeader - .split(",") - .map((part) => part.trim()) - .includes(etag); -} diff --git a/projects/hutch/src/runtime/web/pages/queue/queue.card.route.test.ts b/projects/hutch/src/runtime/web/pages/queue/queue.card.route.test.ts index 2d1487281..78feba928 100644 --- a/projects/hutch/src/runtime/web/pages/queue/queue.card.route.test.ts +++ b/projects/hutch/src/runtime/web/pages/queue/queue.card.route.test.ts @@ -12,7 +12,7 @@ import { createNoopLogError, } from "@packages/test-fixtures"; import { initReadabilityParser } from "@packages/article-parser"; -import { MAX_POLLS } from "../../shared/article-reader/article-reader"; +import { MAX_POLLS } from "@packages/web-shell"; const useApp = useTestServer(); diff --git a/projects/hutch/src/runtime/web/pages/queue/queue.page.ts b/projects/hutch/src/runtime/web/pages/queue/queue.page.ts index 3b95c2092..10cd27d4c 100644 --- a/projects/hutch/src/runtime/web/pages/queue/queue.page.ts +++ b/projects/hutch/src/runtime/web/pages/queue/queue.page.ts @@ -69,7 +69,7 @@ import { CacheableComponent } from "../../conditional-get"; import { isFullyParsed } from "../../shared/article-state/is-fully-parsed"; import { initReaderPermalink } from "./reader-permalink"; import { wantsSiren } from "../../content-negotiation"; -import type { QuerystringFeatureToggle } from "../../feature-toggle"; +import type { QuerystringFeatureToggle } from "@packages/web-shell"; import { SIREN_MEDIA_TYPE, sirenError } from "../../api/siren"; import { toArticleCollectionEntity } from "../../api/collection-siren"; import { toBulkSaveResultEntity } from "../../api/bulk-save-siren"; @@ -79,15 +79,16 @@ import { collectUtmParams } from "../../shared/utm"; import { tabQuery } from "./queue.tabs"; import type { HttpErrorMessageMapping } from "./queue.error"; import { collectStatusFlashParams, importFlashMapping, statusFlashMapping } from "./queue.error"; -import { MAX_POLLS } from "../../shared/article-reader/article-reader"; -import { parsePollParam } from "../../shared/article-reader/poll-param"; +import { MAX_POLLS } from "@packages/web-shell"; +import { parsePollParam } from "@packages/web-shell"; import { toQueueArticleViewModel, toQueueViewModel } from "./queue.viewmodel"; import { QueuePage } from "./queue.component"; import { renderQueueCard, toQueueCardDisplayModel, } from "./queue-card/queue-card.component"; -import { computeQueueCardEtag, etagMatches } from "./queue-card/queue-card.etag"; +import { computeQueueCardEtag } from "./queue-card/queue-card.etag"; +import { etagMatches } from "@packages/web-shell"; import { ReaderPage, formatReaderDocumentTitle } from "../reader/reader.component"; import { NO_CLIENT_ONBOARDING_VERSION, ONBOARDING_VERSION } from "../../onboarding/onboarding.steps"; import { @@ -99,7 +100,7 @@ import { } from "../../onboarding/extension-install"; import { isIosClient, isIosSurface } from "../../onboarding/ios-client"; import type { GetIosAppSignals, RecordIosAnyActivity, RecordIosSavedArticle } from "@packages/provider-contracts/ios-onboarding-signal"; -import type { GetEffectiveAccess } from "../../../domain/access/effective-access"; +import type { GetEffectiveAccess } from "@packages/subscription-access"; /** The dismiss-cookie value a device of this class writes on dismissal and the * GET read expects back: the step-hash {@link ONBOARDING_VERSION} when the device diff --git a/projects/hutch/src/runtime/web/pages/queue/queue.reader.advanced.route.test.ts b/projects/hutch/src/runtime/web/pages/queue/queue.reader.advanced.route.test.ts index 29b985b47..46afc2ecc 100644 --- a/projects/hutch/src/runtime/web/pages/queue/queue.reader.advanced.route.test.ts +++ b/projects/hutch/src/runtime/web/pages/queue/queue.reader.advanced.route.test.ts @@ -11,7 +11,7 @@ import { createNoopLogError, } from "@packages/test-fixtures"; import { initReadabilityParser } from "@packages/article-parser"; -import { MAX_POLLS } from "../../shared/article-reader/article-reader"; +import { MAX_POLLS } from "@packages/web-shell"; const useApp = useTestServer(); diff --git a/projects/hutch/src/runtime/web/pages/queue/queue.reader.route.test.ts b/projects/hutch/src/runtime/web/pages/queue/queue.reader.route.test.ts index 2d9060544..d9a9d5139 100644 --- a/projects/hutch/src/runtime/web/pages/queue/queue.reader.route.test.ts +++ b/projects/hutch/src/runtime/web/pages/queue/queue.reader.route.test.ts @@ -11,7 +11,7 @@ import { createNoopLogError, } from "@packages/test-fixtures"; import { initReadabilityParser } from "@packages/article-parser"; -import { MAX_POLLS } from "../../shared/article-reader/article-reader"; +import { MAX_POLLS } from "@packages/web-shell"; import request from "supertest"; diff --git a/projects/hutch/src/runtime/web/pages/queue/queue.viewmodel.ts b/projects/hutch/src/runtime/web/pages/queue/queue.viewmodel.ts index 1351e8142..491feeb72 100644 --- a/projects/hutch/src/runtime/web/pages/queue/queue.viewmodel.ts +++ b/projects/hutch/src/runtime/web/pages/queue/queue.viewmodel.ts @@ -5,13 +5,13 @@ import { pickExcerpt } from "../../../providers/article-summary/article-summary. import type { ArticleCrawl } from "@packages/provider-contracts/article-crawl"; import type { GeneratedSummary } from "@packages/provider-contracts/article-summary"; import type { ComponentError } from "../../shared/component-error.types"; -import { MAX_POLLS } from "../../shared/article-reader/article-reader"; +import { MAX_POLLS } from "@packages/web-shell"; import { buildCardPollUrl } from "./queue-card/queue-card-poll-url"; import { isCardTerminal } from "./queue-card/is-card-terminal"; import type { QueueUrlState } from "./queue.url"; import { buildQueueUrl } from "./queue.url"; import type { StatusFlash } from "./queue.error"; -import type { EffectiveAccess } from "../../../domain/access/effective-access"; +import type { EffectiveAccess } from "@packages/subscription-access"; export type SubscriptionBannerState = | { state: "none" } diff --git a/projects/hutch/src/runtime/web/pages/view/view.route.test.ts b/projects/hutch/src/runtime/web/pages/view/view.route.test.ts index d948de340..dcf5d15ab 100644 --- a/projects/hutch/src/runtime/web/pages/view/view.route.test.ts +++ b/projects/hutch/src/runtime/web/pages/view/view.route.test.ts @@ -18,7 +18,7 @@ import { } from "@packages/test-fixtures"; import { initInMemoryRateLimit } from "@packages/test-fixtures/providers/rate-limit"; import { calculateReadTime } from "@packages/domain/article"; -import { MAX_POLLS } from "../../shared/article-reader/article-reader"; +import { MAX_POLLS } from "@packages/web-shell"; import type { ViewOpenedEvent } from "@packages/web-analytics"; const GOOGLEBOT = "Googlebot/2.1 (+http://www.google.com/bot.html)"; diff --git a/projects/hutch/src/runtime/web/session.types.ts b/projects/hutch/src/runtime/web/session.types.ts index 91a9d646b..76b08cc98 100644 --- a/projects/hutch/src/runtime/web/session.types.ts +++ b/projects/hutch/src/runtime/web/session.types.ts @@ -1,4 +1,4 @@ -import type { VerificationStatus } from "../domain/access/verification-deadline"; +import type { VerificationStatus } from "@packages/domain/user"; declare global { namespace Express { diff --git a/projects/hutch/src/runtime/web/shared/article-reader/article-reader.test.ts b/projects/hutch/src/runtime/web/shared/article-reader/article-reader.test.ts index 0897451ad..490f8fa32 100644 --- a/projects/hutch/src/runtime/web/shared/article-reader/article-reader.test.ts +++ b/projects/hutch/src/runtime/web/shared/article-reader/article-reader.test.ts @@ -5,7 +5,8 @@ import { ReaderArticleHashId } from "@packages/domain/article"; import type { ArticleCrawl } from "@packages/test-fixtures/providers/article-crawl"; import type { GeneratedSummary } from "@packages/test-fixtures/providers/article-summary"; import type { GlobalArticleData } from "@packages/test-fixtures/providers/article-store"; -import { MAX_POLLS, initArticleReader } from "./article-reader"; +import { MAX_POLLS } from "@packages/web-shell"; +import { initArticleReader } from "./article-reader"; import type { ArticleReaderDeps, ArticleSnapshot, diff --git a/projects/hutch/src/runtime/web/shared/article-reader/article-reader.ts b/projects/hutch/src/runtime/web/shared/article-reader/article-reader.ts index 1ee788aef..f37b98a39 100644 --- a/projects/hutch/src/runtime/web/shared/article-reader/article-reader.ts +++ b/projects/hutch/src/runtime/web/shared/article-reader/article-reader.ts @@ -28,14 +28,7 @@ import type { ReaderState, ResolveReaderStateParams, } from "./article-reader.types"; - -/** - * Hard cap on how many `every 3s` poll ticks an article slot or card emits - * before the client stops. 300 × 3s = 900s, matching the comprehensive-crawl - * orchestrator Lambda timeout. Past that, the orchestrator has given up — - * polling further can't reveal new state. - */ -export const MAX_POLLS = 300; +import { MAX_POLLS } from "@packages/web-shell"; /** * Required input for every poll response. Holds the *full* world state — both diff --git a/projects/hutch/src/runtime/web/trial-display.test.ts b/projects/hutch/src/runtime/web/trial-display.test.ts index 9e7089ad3..e59020d48 100644 --- a/projects/hutch/src/runtime/web/trial-display.test.ts +++ b/projects/hutch/src/runtime/web/trial-display.test.ts @@ -1,5 +1,5 @@ import { toTrialDisplay } from "./trial-display"; -import type { EffectiveAccess } from "../domain/access/effective-access"; +import type { EffectiveAccess } from "@packages/subscription-access"; const ONE_SECOND_MS = 1000; const ONE_MINUTE_MS = 60 * ONE_SECOND_MS; diff --git a/projects/hutch/src/runtime/web/trial-display.ts b/projects/hutch/src/runtime/web/trial-display.ts index 6f5817d4a..9a28e4e7d 100644 --- a/projects/hutch/src/runtime/web/trial-display.ts +++ b/projects/hutch/src/runtime/web/trial-display.ts @@ -1,6 +1,6 @@ import { deriveTrialEscalation, formatTrialRemaining } from "@packages/web-shell"; import type { TrialDisplay } from "@packages/web-shell"; -import type { EffectiveAccess } from "../domain/access/effective-access"; +import type { EffectiveAccess } from "@packages/subscription-access"; export function toTrialDisplay( access: EffectiveAccess, diff --git a/projects/inbox/.c8rc.json b/projects/inbox/.c8rc.json new file mode 100644 index 000000000..e019691ba --- /dev/null +++ b/projects/inbox/.c8rc.json @@ -0,0 +1,9 @@ +{ + "all": true, + "src": "src", + "reporter": ["html", "json-summary"], + "reports-dir": "./coverage", + "resolve": ".", + "source-map": true, + "exclude-after-remap": true +} diff --git a/projects/inbox/Pulumi.prod.yaml b/projects/inbox/Pulumi.prod.yaml new file mode 100644 index 000000000..c98947902 --- /dev/null +++ b/projects/inbox/Pulumi.prod.yaml @@ -0,0 +1,20 @@ +config: + aws:region: ap-southeast-2 + aws:allowedAccountIds: + - '278728209435' + inbox:nodeEnv: production + inbox:staticBaseUrl: https://static.readplace.com + inbox:hutchStack: organization/hutch/prod + inbox:platformStack: organization/platform/prod + inbox:alertEmail: readplace+devops@readplace.com + inbox:inboxAddressDomain: read.place + inbox:dynamodbInboxAddressesTable: hutch-inbox-addresses-prod + inbox:dynamodbInboxEmailsTable: hutch-inbox-emails-prod + inbox:dynamodbInboxEmailLinksTable: hutch-inbox-email-links-prod + inbox:dynamodbSessionsTable: hutch-sessions-d17dbb3 + inbox:dynamodbUsersTable: hutch-users-f90c09e + inbox:dynamodbSubscriptionProvidersTable: hutch-subscription-providers-prod + inbox:rawEmailBucketName: hutch-inbox-raw-email-prod + inbox:contentBucketName: hutch-article-content-prod + inbox:contentMediaCdnDomain: cdn.readplace.com +encryptionsalt: v1:VhoYTtA178g=:v1:vHKe/jtCrEAyN4Vl:rhxUykf2Suj3UgBBcVYk/W2gMO8gOA== diff --git a/projects/inbox/Pulumi.staging.yaml b/projects/inbox/Pulumi.staging.yaml new file mode 100644 index 000000000..602773c65 --- /dev/null +++ b/projects/inbox/Pulumi.staging.yaml @@ -0,0 +1,20 @@ +config: + aws:region: ap-southeast-2 + aws:allowedAccountIds: + - '572337278115' + inbox:nodeEnv: development + inbox:staticBaseUrl: https://static.readplace-staging.com + inbox:hutchStack: organization/hutch/staging + inbox:platformStack: organization/platform/staging + inbox:alertEmail: readplace+staging@readplace.com + inbox:inboxAddressDomain: readplace-staging.com + inbox:dynamodbInboxAddressesTable: hutch-inbox-addresses-staging + inbox:dynamodbInboxEmailsTable: hutch-inbox-emails-staging + inbox:dynamodbInboxEmailLinksTable: hutch-inbox-email-links-staging + inbox:dynamodbSessionsTable: hutch-sessions-d17dbb3 + inbox:dynamodbUsersTable: hutch-users-f90c09e + inbox:dynamodbSubscriptionProvidersTable: hutch-subscription-providers-staging + inbox:rawEmailBucketName: hutch-inbox-raw-email-staging + inbox:contentBucketName: hutch-article-content-staging + inbox:contentMediaCdnDomain: cdn.readplace-staging.com +encryptionsalt: v1:QzA/T+JWV5E=:v1:SnSBwMqISxm5T+2A:RkKs5SsL/OfxIexsSJ9PDwwUhVFghw== diff --git a/projects/inbox/Pulumi.yaml b/projects/inbox/Pulumi.yaml new file mode 100644 index 000000000..cd7060f61 --- /dev/null +++ b/projects/inbox/Pulumi.yaml @@ -0,0 +1,5 @@ +name: inbox +runtime: + name: nodejs + options: + typescript: true diff --git a/projects/inbox/biome.json b/projects/inbox/biome.json new file mode 100644 index 000000000..983c009f3 --- /dev/null +++ b/projects/inbox/biome.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", + "extends": ["../../biome.config.base.json"] +} diff --git a/projects/inbox/enforce-coverage.config.js b/projects/inbox/enforce-coverage.config.js new file mode 100644 index 000000000..4035298f0 --- /dev/null +++ b/projects/inbox/enforce-coverage.config.js @@ -0,0 +1,23 @@ +const baseConfig = require('../../enforce-coverage.config.base'); +const path = require('path'); + +const config = { + ...baseConfig, + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, +}; + +config.enforceCoverage({ + projectRoot: path.resolve(__dirname), + thresholds: config.thresholds, + showTextTable: true, + extraExcludePatterns: [ + ...(config.extraExcludePatterns || []), + // Infrastructure layer — deployed via Pulumi, not testable in CI + 'src/infra/**', + ], +}); diff --git a/projects/inbox/jest.config.js b/projects/inbox/jest.config.js new file mode 100644 index 000000000..70102e008 --- /dev/null +++ b/projects/inbox/jest.config.js @@ -0,0 +1,6 @@ +const baseConfig = require('../../jest.config.base'); + +/** @type {import('jest').Config} */ +module.exports = { + ...baseConfig, +}; diff --git a/projects/inbox/knip.config.ts b/projects/inbox/knip.config.ts new file mode 100644 index 000000000..f797bc667 --- /dev/null +++ b/projects/inbox/knip.config.ts @@ -0,0 +1,30 @@ +import baseConfig from "../../knip.config.base"; +import type { KnipConfig } from "knip"; + +// Strip `workspaces` from the base config because knip is invoked from inside +// this project's directory; the workspaces map is for monorepo-rooted runs. +const { workspaces: _workspaces, ...base } = baseConfig; + +export default { + ...base, + entry: [ + // Lambda + local server entry points referenced by Pulumi infra / start + // script — knip can't follow those references, so list the convention here. + "src/runtime/*.main.ts", + ], + ignoreDependencies: [ + ...(baseConfig.ignoreDependencies || []), + // Used in src/infra (reached via the Pulumi entry point, which knip ignores) + "@packages/hutch-infra-components", + "@pulumi/aws", + "@pulumi/pulumi", + ], + ignoreBinaries: [ + ...(baseConfig.ignoreBinaries || []), + // Used via deploy script, installed globally or via npx + "pulumi", + ], + jest: { + entry: ["src/**/*.test.ts"], + }, +} satisfies KnipConfig; diff --git a/projects/inbox/package.json b/projects/inbox/package.json new file mode 100644 index 000000000..e7405e0d3 --- /dev/null +++ b/projects/inbox/package.json @@ -0,0 +1,63 @@ +{ + "name": "inbox", + "version": "1.0.0", + "description": "Newsletter inbox deployable: /inbox pages plus the email receive/extract/preview pipeline, behind hutch's API Gateway.", + "main": "src/infra/index.ts", + "private": true, + "scripts": { + "start": "node dist/runtime/server.main.js", + "dev": "tsx watch src/runtime/server.main.ts", + "test": "jest --testMatch='**/dist/**/*.test.js' --testTimeout=10000", + "test-with-coverage": "c8 pnpm test && node enforce-coverage.config.js", + "compile": "rm -rf dist && tsc && node scripts/copy-static-assets.js", + "lint": "concurrently --kill-others-on-fail \"tsc --project tsconfig.lint.json\" \"biome lint src\" \"knip\"", + "check-infra": "PULUMI_CONFIG_PASSPHRASE='' pulumi preview --stack \"${PULUMI_STACK:?PULUMI_STACK is not set}\"", + "check": "nx run inbox:check" + }, + "dependencies": { + "@aws-sdk/client-s3": "3.1057.0", + "@aws-sdk/client-sqs": "3.1057.0", + "@packages/article-parser": "workspace:*", + "@packages/article-resource-unique-id": "workspace:*", + "@packages/article-store": "workspace:*", + "@packages/crawl-article": "workspace:*", + "@packages/domain": "workspace:*", + "@packages/finalize-article": "workspace:*", + "@packages/hutch-infra-components": "workspace:*", + "@packages/hutch-logger": "workspace:*", + "@packages/hutch-storage-client": "workspace:*", + "@packages/inbox-store": "workspace:*", + "@packages/provider-contracts": "workspace:*", + "@packages/require-env": "workspace:*", + "@packages/subscription-access": "workspace:*", + "@packages/web-session": "workspace:*", + "@packages/web-shell": "workspace:*", + "compression": "1.8.1", + "cookie-parser": "1.4.7", + "express": "5.2.1", + "helmet": "8.2.0", + "serverless-http": "4.0.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@packages/test-fixtures": "workspace:*", + "@packages/web-test-harness": "workspace:*", + "@pulumi/aws": "7.32.0", + "@pulumi/pulumi": "3.244.0", + "@types/aws-lambda": "8.10.161", + "@types/compression": "1.8.1", + "@types/cookie-parser": "1.4.10", + "@types/express": "5.0.6", + "@types/jest": "30.0.0", + "@types/jsdom": "21.1.7", + "@types/node": "22.19.19", + "@types/supertest": "7.2.0", + "c8": "11.0.0", + "concurrently": "10.0.0", + "jest": "30.4.2", + "jsdom": "26.1.0", + "supertest": "7.2.2", + "tsx": "4.22.3", + "typescript": "6.0.3" + } +} diff --git a/projects/inbox/project.json b/projects/inbox/project.json new file mode 100644 index 000000000..6334b4b80 --- /dev/null +++ b/projects/inbox/project.json @@ -0,0 +1,34 @@ +{ + "name": "inbox", + "targets": { + "install-deps": { + "command": "test -d projects/inbox/node_modules || pnpm install --frozen-lockfile", + "options": { "cwd": "{workspaceRoot}" }, + "cache": false + }, + "compile": { + "dependsOn": ["install-deps", "^compile"] + }, + "deploy-infra": { + "command": "PULUMI_CONFIG_PASSPHRASE='' pulumi up --stack \"${PULUMI_STACK:?PULUMI_STACK is not set}\" $([ \"$CI\" = true ] && echo --yes)", + "options": { "cwd": "{projectRoot}" }, + "dependsOn": ["compile"] + }, + "post-deploy": { + "command": "bash scripts/post-deploy.sh", + "options": { "cwd": "{projectRoot}" } + }, + "check": { + "command": "true", + "options": { "cwd": "{projectRoot}" }, + "dependsOn": ["lint", "test-with-coverage"] + }, + "lint": { + "dependsOn": ["compile"] + }, + "test-with-coverage": { + "dependsOn": ["compile"] + }, + "test": {} + } +} diff --git a/projects/inbox/scripts/copy-static-assets.js b/projects/inbox/scripts/copy-static-assets.js new file mode 100644 index 000000000..c4ecca2d7 --- /dev/null +++ b/projects/inbox/scripts/copy-static-assets.js @@ -0,0 +1,37 @@ +/** + * Copy non-TypeScript files (templates, markdown posts, CSS) from src/ to dist/. + * + * The compiler emits only JavaScript and type declarations; components load + * their templates and posts at runtime by reading files alongside the compiled + * module, so those assets must be mirrored into dist next to the compiled code. + */ +const fs = require("node:fs"); +const path = require("node:path"); + +const SRC_DIR = path.join(__dirname, "../src"); +const DIST_DIR = path.join(__dirname, "../dist"); + +const EXTENSIONS = [".css", ".html", ".md", ".txt"]; + +function shouldCopy(filePath) { + return EXTENSIONS.some((ext) => filePath.endsWith(ext)); +} + +function copyStaticAssets(srcDir, distDir) { + let count = 0; + for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { + const srcPath = path.join(srcDir, entry.name); + const distPath = path.join(distDir, entry.name); + if (entry.isDirectory()) { + count += copyStaticAssets(srcPath, distPath); + } else if (shouldCopy(entry.name)) { + fs.mkdirSync(path.dirname(distPath), { recursive: true }); + fs.copyFileSync(srcPath, distPath); + count++; + } + } + return count; +} + +const copied = copyStaticAssets(SRC_DIR, DIST_DIR); +console.log(`inbox copy-static-assets: copied ${copied} files from src/ to dist/.`); diff --git a/projects/inbox/scripts/post-deploy.sh b/projects/inbox/scripts/post-deploy.sh new file mode 100755 index 000000000..fca354d16 --- /dev/null +++ b/projects/inbox/scripts/post-deploy.sh @@ -0,0 +1,60 @@ +#!/bin/bash +set -euo pipefail + +STACK="${PULUMI_STACK:?PULUMI_STACK is not set}" + +USER_AGENT="Readplace-Deploy-Verify/1.0" + +RAW_URL=$(pulumi stack output apiUrl --stack "$STACK") +# Strip /$default suffix that API Gateway appends to the no-custom-domain URL +URL="${RAW_URL%/\$default}" +echo "Verifying $STACK inbox deployment at: $URL" + +# /inbox is auth-gated: logged out it must answer 303 See Other with a Location +# ending in /login. Asserting the exact status + target proves the inbox Lambda +# (not hutch's $default catch-all) answered the route. Deeper checks (address +# provisioning, email list) sit behind login and feature toggles, so this is +# the whole unauthenticated surface. +verify_auth_redirect() { + local path="$1" + local attempt response status location + for attempt in 1 2 3; do + response=$(curl --silent --show-error --max-time 30 --user-agent "$USER_AGENT" --output /dev/null --write-out '%{http_code} %{redirect_url}' "$URL$path") || response="" + status="${response%% *}" + location="${response#* }" + # Compare on the path only: the runtime may append a ?return=… query. + if [ "$status" = "303" ] && [[ "${location%%\?*}" == */login ]]; then + echo "OK: $path -> $status $location" + return 0 + fi + echo "Attempt $attempt failed for $URL$path (status=${status:-none} location=${location:-none}) — retrying in 5s" + sleep 5 + done + echo "--- Diagnostic dump for $URL$path ---" + curl --silent --show-error --max-time 30 --user-agent "$USER_AGENT" --dump-header - "$URL$path" | head -20 + return 1 +} + +verify_auth_redirect "/inbox" + +# --- SES email round-trip smoke (staging only — not yet enabled) --- +# End-to-end check of the receive → extract pipeline: send a message to a +# dedicated smoke address on the staging inbox domain, then poll until the +# email row (and its extracted link row) lands. It needs one-time seeding +# before it can ever pass: create a permanent smoke-test user on staging and +# provision its inbox address (e.g. smoke@readplace-staging.com) through the +# /inbox UI, then record that address below. Until that address exists, mail +# to it is dropped as an unknown recipient, so the block stays commented out +# to keep post-deploy non-blocking. +# +# if [ "$STACK" = "organization/inbox/staging" ]; then +# SMOKE_ADDRESS="smoke@readplace-staging.com" +# SMOKE_SUBJECT="post-deploy smoke $(date +%s)" +# aws ses send-email \ +# --from "readplace+staging@readplace.com" \ +# --destination "ToAddresses=$SMOKE_ADDRESS" \ +# --message "Subject={Data=$SMOKE_SUBJECT},Body={Text={Data=https://example.com/}}" +# # Poll the staging inbox-emails table (or the /inbox page as the smoke user) +# # until a row with $SMOKE_SUBJECT appears, then assert its extracted link +# # row for https://example.com/ exists. +# fi diff --git a/projects/inbox/src/infra/index.ts b/projects/inbox/src/infra/index.ts new file mode 100644 index 000000000..847fa5e2c --- /dev/null +++ b/projects/inbox/src/infra/index.ts @@ -0,0 +1,442 @@ +import * as aws from "@pulumi/aws"; +import * as pulumi from "@pulumi/pulumi"; +import { + HutchAPIGatewayLambdaRoute, + HutchDynamoDBAccess, + HutchEventBus, + HutchLambda, + HutchS3ReadWrite, + HutchSQS, + HutchSQSBackedLambda, +} from "@packages/hutch-infra-components/infra"; +import { + CrawlEmailLinkPreview, + EmailReceivedEvent, +} from "@packages/hutch-infra-components"; + +/** + * inbox is deployed as its own Lambda behind hutch's existing API Gateway: + * more-specific routes (GET /inbox, ANY /inbox/{proxy+}) take precedence over + * hutch's $default, so readplace.com/inbox is served here while everything else + * falls through to hutch. The coupling is deploy-time and one-way — this stack + * reads hutch's API id/exec-arn via a StackReference and attaches its own + * integration + routes + invoke permission. The inbound-email pipeline + * (receive → extract links → crawl previews) is re-homed from hutch with + * `inbox-`-prefixed names throughout: EventBridge PutRule is an upsert and + * Lambda/SQS physical names are account-scoped, so a same-named resource here + * would steal hutch's live rule/queue instead of standing up beside it. + */ +const config = new pulumi.Config(); +const nodeEnv = config.require("nodeEnv"); +const staticBaseUrl = config.require("staticBaseUrl"); +const alertEmail = config.require("alertEmail"); +const inboxAddressDomain = config.require("inboxAddressDomain"); +const rawEmailBucketName = config.require("rawEmailBucketName"); +const contentBucketName = config.require("contentBucketName"); +const hutchStackName = config.require("hutchStack"); + +// The content-media CDN over the shared article-content bucket is owned by +// save-link; the link-preview crawler uploads lead images there and needs the +// CDN base URL. That URL is the CDN's custom domain (a config constant), so +// derive it here the way save-link does instead of a cross-stack requireOutput, +// which would couple deploy order (see the infrastructure-design skill). +const imagesCdnBaseUrl = `https://${config.require("contentMediaCdnDomain")}`; + +const hutchStack = new pulumi.StackReference(hutchStackName); +const apiGatewayId = hutchStack.requireOutput("apiGatewayId"); +const apiGatewayExecutionArn = hutchStack.requireOutput("apiGatewayExecutionArn"); +const hutchApiUrl = hutchStack.requireOutput("apiUrl"); +// The SES receipt-rule SNS topic stays with hutch (SES receipt rules and the +// inbound-mail DNS live there). Its ARN is genuinely deploy-time — not a value +// config could carry — so this is a legitimate project→project StackReference +// edge (infrastructure-design: "Don't StackReference a Value You Could Put in +// Config", exception 3). +const inboxNotificationTopicArn = hutchStack + .requireOutput("inboxNotificationTopicArn") + .apply(String); + +// Table and bucket names are config constants (identical across deploys), so +// this stack reads them from its own config rather than via a StackReference — +// a cross-stack read of a value you could put in config couples deploy order +// and breaks the first deploy after any new hutch output (infrastructure-design: +// "Don't StackReference a Value You Could Put in Config"). ARNs are derived +// from account + region rather than read back. +const awsRegion = new pulumi.Config("aws").require("region"); +const awsAccountId = pulumi.output(aws.getCallerIdentity({})).accountId; +const tableNames = { + inboxAddresses: config.require("dynamodbInboxAddressesTable"), + inboxEmails: config.require("dynamodbInboxEmailsTable"), + inboxEmailLinks: config.require("dynamodbInboxEmailLinksTable"), + sessions: config.require("dynamodbSessionsTable"), + users: config.require("dynamodbUsersTable"), + subscriptionProviders: config.require("dynamodbSubscriptionProvidersTable"), +}; + +function tableArn(tableName: string): pulumi.Output { + return pulumi.interpolate`arn:aws:dynamodb:${awsRegion}:${awsAccountId}:table/${tableName}`; +} + +const eventBus = HutchEventBus.fromPlatformStack(config); + +// --- Web Lambda (GET /inbox + ANY /inbox/{proxy+}) --- + +const webInboxTables = new HutchDynamoDBAccess("inbox-web-inbox-tables", { + tables: [ + // The addresses userId-index backs the address reverse-lookup per page view. + { arn: tableArn(tableNames.inboxAddresses), includeIndexes: true }, + { arn: tableArn(tableNames.inboxEmails), includeIndexes: false }, + { arn: tableArn(tableNames.inboxEmailLinks), includeIndexes: false }, + ], + // Mirrors the actions hutch's web Lambda held on these tables before the + // /inbox pages re-homed here. + actions: [ + "dynamodb:GetItem", + "dynamodb:BatchGetItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + "dynamodb:Query", + "dynamodb:Scan", + ], +}); + +// GetItem authenticates every request; UpdateItem because verifying an inbox +// address flips the session's email-verified flag (markSessionEmailVerified). +const webSessions = new HutchDynamoDBAccess("inbox-web-sessions", { + tables: [{ arn: tableArn(tableNames.sessions), includeIndexes: false }], + actions: ["dynamodb:GetItem", "dynamodb:UpdateItem"], +}); + +// users is read-only here: Query the userId-index to resolve the signed-in +// user's row (same shape as hutch's send-user-digest users grant). +const webUsersRead = new HutchDynamoDBAccess("inbox-web-users-read", { + tables: [{ arn: tableArn(tableNames.users), includeIndexes: true }], + actions: ["dynamodb:Query"], +}); + +const webSubscriptionProvidersRead = new HutchDynamoDBAccess( + "inbox-web-subscription-providers-read", + { + tables: [{ arn: tableArn(tableNames.subscriptionProviders), includeIndexes: false }], + actions: ["dynamodb:GetItem"], + }, +); + +const webLambda = new HutchLambda("inbox-web", { + entryPoint: "./src/runtime/lambda.main.ts", + outputDir: ".lib/inbox-web", + assetDir: "./src/runtime", + memorySize: 256, + timeout: 10, + environment: { + NODE_ENV: nodeEnv, + // The inbox is served same-origin under hutch (readplace.com/inbox), so + // hutch's origin is the origin the inbox is served on. + APP_ORIGIN: hutchApiUrl, + STATIC_BASE_URL: staticBaseUrl, + INBOX_ADDRESS_DOMAIN: inboxAddressDomain, + DYNAMODB_INBOX_ADDRESSES_TABLE: tableNames.inboxAddresses, + DYNAMODB_INBOX_EMAILS_TABLE: tableNames.inboxEmails, + DYNAMODB_INBOX_EMAIL_LINKS_TABLE: tableNames.inboxEmailLinks, + DYNAMODB_SESSIONS_TABLE: tableNames.sessions, + DYNAMODB_USERS_TABLE: tableNames.users, + DYNAMODB_SUBSCRIPTION_PROVIDERS_TABLE: tableNames.subscriptionProviders, + CONTENT_BUCKET_NAME: contentBucketName, + /** Same-origin fragment endpoint served by blog-site behind this same API + * Gateway (/blog/{proxy+} routes there). The banner source is cached and + * fail-open, so the extra gateway hop is fine for a decorative banner. */ + CHANGELOG_BANNER_URL: pulumi.interpolate`${hutchApiUrl}/blog/changelog-banner`, + }, + policies: [ + ...webInboxTables.policies, + ...webSessions.policies, + ...webUsersRead.policies, + ...webSubscriptionProvidersRead.policies, + // Reads the sanitized email bodies the receive worker wrote to the shared + // content bucket. + ...HutchS3ReadWrite.readPoliciesForBucket("inbox-web-content", contentBucketName), + ], +}); + +const inboxRoutes = new HutchAPIGatewayLambdaRoute("inbox-web", { + apiGatewayId, + apiGatewayExecutionArn, + lambda: webLambda, + routeKeys: ["GET /inbox", "ANY /inbox/{proxy+}"], +}); + +// --- Inbound email receive worker Lambda --- +// Drains the SES→SNS receipt notifications: fetches the raw .eml from the raw +// bucket, resolves each recipient, parses + sanitizes the body into the content +// bucket, writes a row per recipient, and publishes EmailReceivedEvent. Expected +// catch-all-MX conditions (unknown or disabled recipient) record an audit row and +// ACK — never paging. Oversize / unparseable mail also records an audit row, but +// only pages (fails to the DLQ so the HutchSQSBackedLambda alarm fires) when a +// real, enabled recipient is affected; junk to guessed addresses just ACKs. The +// immutable raw .eml is kept forever as the audit trail (★15 degrade-with-alert). +const receiveEmailDynamodb = new HutchDynamoDBAccess("inbox-receive-email-dynamodb", { + tables: [ + { arn: tableArn(tableNames.inboxEmails), includeIndexes: false }, + { arn: tableArn(tableNames.inboxAddresses), includeIndexes: false }, + ], + // findByAddress is a GetItem and putEmail a (conditional) PutItem — no Query. + actions: ["dynamodb:GetItem", "dynamodb:PutItem"], +}); + +const receiveEmailQueue = new HutchSQS("inbox-receive-email", { + // Matches the worker timeout so an in-flight parse cannot be redelivered. + visibilityTimeoutSeconds: 120, +}); + +const receiveEmailLambda = new HutchLambda("inbox-receive-email", { + entryPoint: "./src/runtime/receive-email.main.ts", + outputDir: ".lib/inbox-receive-email", + assetDir: "./src/runtime", + memorySize: 1024, + timeout: 120, + environment: { + DYNAMODB_INBOX_EMAILS_TABLE: tableNames.inboxEmails, + DYNAMODB_INBOX_ADDRESSES_TABLE: tableNames.inboxAddresses, + RAW_EMAIL_BUCKET_NAME: rawEmailBucketName, + CONTENT_BUCKET_NAME: contentBucketName, + EVENT_BUS_NAME: eventBus.eventBusName, + // 20 MiB — half SES's ~40 MB hard inbound cap; bounds parse memory. + INBOX_MAX_EMAIL_BYTES: String(20 * 1024 * 1024), + }, + policies: [ + ...receiveEmailDynamodb.policies, + // Reads the raw bucket (the .eml) and only writes the content bucket (the + // sanitized body) — no content-bucket read. + ...HutchS3ReadWrite.readPoliciesForBucket("inbox-receive-email-raw-read", rawEmailBucketName), + ...HutchS3ReadWrite.writePoliciesForBucket( + "inbox-receive-email-content-write", + contentBucketName, + ), + ], +}); + +eventBus.grantPublish(receiveEmailLambda); + +new HutchSQSBackedLambda("inbox-receive-email", { + lambda: receiveEmailLambda, + queue: receiveEmailQueue, + alertEmailDLQEntry: alertEmail, + batchSize: 1, +}); + +// SES → SNS → SQS bridge: SES cannot target SQS directly, so the receipt rule +// publishes to hutch's inbox-mail topic (read via the StackReference above) and +// the receive queue subscribes here with raw message delivery, so the SQS body +// is the SES notification JSON the handler parses. The queue policy lets only +// that topic enqueue. +const receiveEmailQueuePolicy = new aws.sqs.QueuePolicy("inbox-receive-email-sns-policy", { + queueUrl: receiveEmailQueue.queueUrl, + policy: pulumi + .all([receiveEmailQueue.queueArn, inboxNotificationTopicArn]) + .apply(([queueArn, topicArn]) => + JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "sns.amazonaws.com" }, + Action: "sqs:SendMessage", + Resource: queueArn, + Condition: { ArnEquals: { "aws:SourceArn": topicArn } }, + }, + ], + }), + ), +}); + +new aws.sns.TopicSubscription( + "inbox-receive-email-subscription", + { + topic: inboxNotificationTopicArn, + protocol: "sqs", + endpoint: receiveEmailQueue.queueArn, + rawMessageDelivery: true, + }, + { dependsOn: [receiveEmailQueuePolicy] }, +); + +// --- Inbox link previews (extract → crawl) --- +// EmailReceivedEvent → extract-email-links re-derives the body from the raw .eml, +// extracts links, writes pending rows, and fans out one CrawlEmailLinkPreview per +// link (★14 cap + truncate-degrade-with-dedicated-alert-queue). Each CrawlEmailLinkPreview → +// crawl-email-link-preview crawls a preview WITHOUT saving to /queue (★16 SSRF +// guard inherited from crawlAndFinalize). Neither Lambda is granted the +// articles/user-articles tables — the "nothing saved to the queue" invariant is +// enforced at the IAM boundary. +const extractEmailLinksDynamodb = new HutchDynamoDBAccess("inbox-extract-email-links-dynamodb", { + tables: [ + { arn: tableArn(tableNames.inboxEmails), includeIndexes: false }, + { arn: tableArn(tableNames.inboxEmailLinks), includeIndexes: false }, + ], + // getEmail (GetItem); putLink/putLinksMeta (PutItem); the conditional put needs + // no Query, and listing is the web layer's job. + actions: ["dynamodb:GetItem", "dynamodb:PutItem"], +}); + +const extractEmailLinksQueue = new HutchSQS("inbox-extract-email-links", { + visibilityTimeoutSeconds: 120, +}); + +// Truncation is a successful degradation (the first N previews still shipped), not +// a processing failure — so it must NOT land in the consumer's own failure DLQ. +// There it would (a) make the DLQ depth alarm ambiguous between a genuine "email +// never extracted" and a benign "email truncated", and (b) re-enter the source +// queue on redrive and bounce straight back (the synthetic body has no `.detail`). +// It gets a dedicated sink whose send-rate alarm → SNS email instead, so the +// failure DLQ's "messages awaiting redrive" contract stays unambiguous. +const truncationAlertQueue = new aws.sqs.Queue("inbox-extract-email-links-truncated-alert", { + name: "inbox-extract-email-links-truncated-alert", + // Nothing consumes this queue — it is an audit trail of truncated emails. The + // alarm below fires on send RATE, not depth, so these retained messages no + // longer pin it in ALARM; 14 days is just how long the audit trail survives. + messageRetentionSeconds: 1209600, +}); + +const truncationAlertTopic = new aws.sns.Topic("inbox-extract-email-links-truncated-alert-topic", { + name: "inbox-extract-email-links-truncated-alert-topic", +}); + +new aws.sns.TopicSubscription("inbox-extract-email-links-truncated-alert-email", { + topic: truncationAlertTopic.arn, + protocol: "email", + endpoint: alertEmail, +}); + +// Alarm on the SEND RATE (a count metric), not queue DEPTH (a gauge). Nothing +// consumes this queue, so an ApproximateNumberOfMessagesVisible>=1 depth alarm +// would stay in ALARM until the message expires 14 days later (or an operator +// purges it), and a second truncation while already in ALARM would never +// re-notify. NumberOfMessagesSent has a datapoint only in the period a message is +// enqueued, so with treatMissingData "notBreaching" the alarm fires on each +// truncation burst and auto-resets the next empty period — re-firing for the next. +new aws.cloudwatch.MetricAlarm("inbox-extract-email-links-truncated-alarm", { + name: "inbox-extract-email-links-truncated-alarm", + comparisonOperator: "GreaterThanOrEqualToThreshold", + evaluationPeriods: 1, + metricName: "NumberOfMessagesSent", + namespace: "AWS/SQS", + period: 300, + statistic: "Sum", + threshold: 1, + treatMissingData: "notBreaching", + alarmDescription: + "inbox-extract-email-links hit the per-email link cap and truncated an email (the first N previews still shipped)", + dimensions: { QueueName: truncationAlertQueue.name }, + alarmActions: [truncationAlertTopic.arn], +}); + +const extractEmailLinksLambda = new HutchLambda("inbox-extract-email-links", { + entryPoint: "./src/runtime/extract-email-links.main.ts", + outputDir: ".lib/inbox-extract-email-links", + assetDir: "./src/runtime", + memorySize: 1024, + timeout: 120, + environment: { + DYNAMODB_INBOX_EMAILS_TABLE: tableNames.inboxEmails, + DYNAMODB_INBOX_EMAIL_LINKS_TABLE: tableNames.inboxEmailLinks, + RAW_EMAIL_BUCKET_NAME: rawEmailBucketName, + EVENT_BUS_NAME: eventBus.eventBusName, + // A typical newsletter has < 30 links; 200 is generous headroom before the + // per-email cap truncates and the working path still ships the first 200. + INBOX_MAX_LINKS_PER_EMAIL: String(200), + EXTRACT_LINKS_TRUNCATION_ALERT_QUEUE_URL: truncationAlertQueue.url, + }, + policies: [ + ...extractEmailLinksDynamodb.policies, + // Reads the raw .eml to re-derive the body; never writes any bucket. + ...HutchS3ReadWrite.readPoliciesForBucket( + "inbox-extract-email-links-raw-read", + rawEmailBucketName, + ), + // The truncated-degrade alert is an explicit SendMessage to the dedicated + // alert queue (NOT the failure DLQ), whose own send-rate alarm pages the operator. + { + name: "inbox-extract-email-links-truncated-alert-send-pol", + policy: pulumi.output(truncationAlertQueue.arn).apply((arn) => + JSON.stringify({ + Version: "2012-10-17", + Statement: [{ Effect: "Allow", Action: ["sqs:SendMessage"], Resource: [arn] }], + }), + ), + }, + ], +}); + +eventBus.grantPublish(extractEmailLinksLambda); + +const extractEmailLinksWithSQS = new HutchSQSBackedLambda("inbox-extract-email-links", { + lambda: extractEmailLinksLambda, + queue: extractEmailLinksQueue, + alertEmailDLQEntry: alertEmail, + batchSize: 1, +}); + +// The explicit rule name keeps this subscription distinct from the rule hutch's +// stack still owns for the same event — PutRule is an upsert, so a same-named +// rule would steal hutch's live subscription instead of adding one. +eventBus.subscribe(EmailReceivedEvent, extractEmailLinksWithSQS, { + name: "inbox-extract-email-links", +}); + +const crawlEmailLinkPreviewDynamodb = new HutchDynamoDBAccess( + "inbox-crawl-email-link-preview-dynamodb", + { + tables: [{ arn: tableArn(tableNames.inboxEmailLinks), includeIndexes: false }], + // setLinkOutcome is an UpdateItem; getLink is not used by the worker. + actions: ["dynamodb:UpdateItem"], + }, +); + +const crawlEmailLinkPreviewQueue = new HutchSQS("inbox-crawl-email-link-preview", { + // A single dead/slow origin retries and DLQs in isolation; matches the worker + // timeout so an in-flight crawl cannot be redelivered. + visibilityTimeoutSeconds: 120, +}); + +const crawlEmailLinkPreviewLambda = new HutchLambda("inbox-crawl-email-link-preview", { + entryPoint: "./src/runtime/crawl-email-link-preview.main.ts", + outputDir: ".lib/inbox-crawl-email-link-preview", + assetDir: "./src/runtime", + memorySize: 1024, + timeout: 120, + environment: { + DYNAMODB_INBOX_EMAIL_LINKS_TABLE: tableNames.inboxEmailLinks, + // Uploads each lead image to the shared content bucket, fronted by the + // save-link content-media CDN (derived from config above). + CONTENT_BUCKET_NAME: contentBucketName, + IMAGES_CDN_BASE_URL: imagesCdnBaseUrl, + }, + policies: [ + ...crawlEmailLinkPreviewDynamodb.policies, + // Only writes the content bucket (the lead-image thumbnail) — no read, and + // no access to the articles/user-articles tables (nothing saved to /queue). + ...HutchS3ReadWrite.writePoliciesForBucket( + "inbox-crawl-email-link-preview-content-write", + contentBucketName, + ), + ], +}); + +const crawlEmailLinkPreviewWithSQS = new HutchSQSBackedLambda("inbox-crawl-email-link-preview", { + lambda: crawlEmailLinkPreviewLambda, + queue: crawlEmailLinkPreviewQueue, + alertEmailDLQEntry: alertEmail, + batchSize: 1, +}); + +eventBus.subscribe(CrawlEmailLinkPreview, crawlEmailLinkPreviewWithSQS, { + name: "inbox-crawl-email-link-preview", +}); + +export const functionName = webLambda.functionName; +export const routeKeys = inboxRoutes.routes.map((route) => route.routeKey); + +/** The inbox web Lambda has no URL of its own — it answers on hutch's API + * Gateway under /inbox. Re-export hutch's apiUrl so post-deploy can smoke-test + * the live routes without a second StackReference at verification time. */ +export const apiUrl = hutchApiUrl; diff --git a/projects/inbox/src/infra/tsconfig.json b/projects/inbox/src/infra/tsconfig.json new file mode 100644 index 000000000..064efbca7 --- /dev/null +++ b/projects/inbox/src/infra/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../tsconfig.config.base.json", + "compilerOptions": { + "outDir": "./bin", + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "incremental": false + }, + "include": ["index.ts"] +} diff --git a/projects/inbox/src/runtime/app.route.test.ts b/projects/inbox/src/runtime/app.route.test.ts new file mode 100644 index 000000000..870d595b6 --- /dev/null +++ b/projects/inbox/src/runtime/app.route.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import request from "supertest"; +import { + CHANGELOG_DISMISS_COOKIE_NAME, + type ChangelogBanner, + isChangelogVersion, +} from "@packages/web-shell"; +import { + TEST_APP_ORIGIN, + createDefaultTestAppFixture, +} from "@packages/test-fixtures"; +import { buildHarness } from "@packages/web-test-harness"; +import type { RunningServer } from "@packages/web-test-harness"; +import { createInboxTestApp, loginAgent, useTestServer } from "./test-app"; + +const useApp = useTestServer(); + +const CHANGELOG_VERSION = "a1b2c3d4"; +assert(isChangelogVersion(CHANGELOG_VERSION)); +const CHANGELOG: ChangelogBanner = { + hook: "I added keyboard shortcuts to the reader", + href: "/blog/keyboard-shortcuts?utm_source=changelog-banner&utm_medium=internal&utm_content=read-more", + version: CHANGELOG_VERSION, +}; + +describe("Inbox app composition", () => { + it("serves nothing outside /inbox — the rest of the origin belongs to hutch", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + + const response = await request(harness.server).get("/queue"); + + expect(response.status).toBe(404); + }); + + it("resolves the hutch session cookie into an authenticated request", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + const agent = await loginAgent(harness.server, harness.auth); + + const response = await agent.get("/inbox?feature=email"); + + expect(response.status).toBe(200); + }); + + it("degrades to guest and logs when the session lookup fails, instead of 500ing", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + const errors: string[] = []; + fixture.shared.logError = (message) => { + errors.push(message); + }; + fixture.auth.getSessionUserId = async () => { + throw new Error("dynamo down"); + }; + const harness = useApp(fixture); + const agent = await loginAgent(harness.server, harness.auth); + + const response = await agent.get("/inbox?feature=email"); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/login"); + expect(errors.some((m) => m.includes("session lookup failed"))).toBe(true); + }); + + it("renders the announced changelog banner on an inbox page and suppresses it once dismissed", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + const harness: RunningServer & ReturnType = buildHarness( + createInboxTestApp(fixture, { getChangelogBanner: async () => CHANGELOG }), + ); + try { + const agent = await loginAgent(harness.server, harness.auth); + + const announced = await agent.get("/inbox?feature=email"); + expect(announced.status).toBe(200); + const banner = new JSDOM(announced.text).window.document.querySelector( + "[data-test-changelog-banner]", + ); + assert(banner, "the changelog banner shell must render"); + expect(banner.classList.contains("changelog-banner--visible")).toBe(true); + expect(banner.textContent).toContain(CHANGELOG.hook); + + const dismissed = await agent + .get("/inbox?feature=email") + .set("Cookie", `${CHANGELOG_DISMISS_COOKIE_NAME}=${CHANGELOG_VERSION}`); + expect(dismissed.status).toBe(200); + const hiddenShell = new JSDOM(dismissed.text).window.document.querySelector( + "[data-test-changelog-banner]", + ); + assert(hiddenShell, "the dismissed banner keeps its hidden shell"); + expect(hiddenShell.classList.contains("changelog-banner--hidden")).toBe(true); + } finally { + await harness.close(); + } + }); +}); diff --git a/projects/inbox/src/runtime/app.ts b/projects/inbox/src/runtime/app.ts new file mode 100644 index 000000000..6c68f9227 --- /dev/null +++ b/projects/inbox/src/runtime/app.ts @@ -0,0 +1,107 @@ +import express, { type Express, type NextFunction, type Request, type Response } from "express"; +import cookieParser from "cookie-parser"; +import type { + InboxAddressStore, + InboxEmailLinkStore, + InboxEmailStore, +} from "@packages/domain/inbox"; +import type { ContentProvider } from "@packages/provider-contracts/article-store"; +import type { + FindUserById, + MarkSessionEmailVerified, +} from "@packages/provider-contracts/auth"; +import type { FindSubscriptionByUserId } from "@packages/provider-contracts/subscription-providers"; +import type { ResolveLogin } from "@packages/web-session"; +import { QuerystringFeatureToggle } from "@packages/web-shell"; +import { initGetEffectiveAccess } from "@packages/subscription-access"; +import { initBuildBannerState } from "./web/banner-state"; +import type { GetChangelogBanner } from "./web/changelog-banner-source"; +import { changelogDismissMiddleware } from "./web/changelog-dismiss.middleware"; +import { requireAuth } from "./web/middleware/require-auth"; +import { requireNotLocked } from "./web/middleware/require-not-locked.middleware"; +import { initRequireWriteAccess } from "./web/middleware/require-write-access.middleware"; +import { initResolveVerificationStatus } from "./web/middleware/resolve-verification-status.middleware"; +import { initInboxRoutes } from "./web/pages/inbox/inbox.page"; +import "./web/session.types"; + +export const PORT = 3300; + +/** Composition root for the inbox deployable: the /inbox pages behind hutch's + * API Gateway. Env-free — the entry points read the environment and pass + * everything in — so the whole app composes against in-memory fixtures in + * tests. Login state is resolved from hutch's session cookie exactly as hutch + * resolves it (same middleware order: cookies → changelog dismissal → login → + * verification standing → the auth-gated router), so a reader moving between + * hutch pages and these pages never sees a different standing. */ +export function createInboxApp( + config: { inboxAddressDomain: string }, + deps: { + resolveLogin: ResolveLogin; + findUserById: FindUserById; + markSessionEmailVerified: MarkSessionEmailVerified; + findSubscriptionByUserId: FindSubscriptionByUserId; + getChangelogBanner: GetChangelogBanner; + inboxAddressStore: InboxAddressStore; + inboxEmailStore: InboxEmailStore; + inboxEmailLinkStore: InboxEmailLinkStore; + readEmailContent: ContentProvider; + logError: (message: string, error?: Error) => void; + now: () => Date; + }, +): Express { + const app: Express = express(); + app.disable("x-powered-by"); + + app.use(express.urlencoded({ extended: true })); + app.use(cookieParser()); + app.use(changelogDismissMiddleware); + + app.use(async (req: Request, _res: Response, next: NextFunction) => { + const login = await deps.resolveLogin(req.headers.cookie); + if (login.isAuthenticated) { + req.userId = login.userId; + req.emailVerified = login.emailVerified; + } + next(); + }); + + app.use( + initResolveVerificationStatus({ + findUserById: deps.findUserById, + markSessionEmailVerified: deps.markSessionEmailVerified, + now: deps.now, + }), + ); + + const getEffectiveAccess = initGetEffectiveAccess({ + findSubscriptionByUserId: deps.findSubscriptionByUserId, + now: deps.now, + }); + const requireWriteAccess = initRequireWriteAccess({ + findSubscriptionByUserId: deps.findSubscriptionByUserId, + now: deps.now, + }); + const buildBannerState = initBuildBannerState({ + getEffectiveAccess, + getChangelogBanner: deps.getChangelogBanner, + now: deps.now, + }); + const featureToggle = new QuerystringFeatureToggle(); + + const inboxRouter = initInboxRoutes({ + featureToggle, + inboxAddressStore: deps.inboxAddressStore, + inboxEmailStore: deps.inboxEmailStore, + inboxEmailLinkStore: deps.inboxEmailLinkStore, + readEmailContent: deps.readEmailContent, + inboxAddressDomain: config.inboxAddressDomain, + logError: deps.logError, + buildBannerState, + requireNotLocked, + requireWriteAccess, + now: deps.now, + }); + app.use("/inbox", requireAuth, inboxRouter); + + return app; +} diff --git a/projects/inbox/src/runtime/crawl-email-link-preview.main.ts b/projects/inbox/src/runtime/crawl-email-link-preview.main.ts new file mode 100644 index 000000000..a9e877ddf --- /dev/null +++ b/projects/inbox/src/runtime/crawl-email-link-preview.main.ts @@ -0,0 +1,93 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { + initReadabilityParser, + linkedinSiteRules, + mediumSiteRules, + theInformationSiteRules, +} from "@packages/article-parser"; +import { + CRAWL_PERSONAS, + initCrawlArticle, + initCrawlFetch, + initFetchThumbnailImage, + initXTwitterSiteRules, +} from "@packages/crawl-article"; +import { isBlockedIpAddress } from "@packages/domain/article"; +import { initCrawlAndFinalizeArticle, initFinalizeArticle } from "@packages/finalize-article"; +import { HutchLogger, consoleLogger } from "@packages/hutch-logger"; +import { createDynamoDocumentClient } from "@packages/hutch-storage-client"; +import { requireEnv } from "@packages/require-env"; +import { initCrawlEmailLinkPreviewHandler } from "./domain/inbox/crawl-email-link-preview-handler"; +import { initS3PutImageObject } from "./providers/article-image/s3-put-image-object"; +import { initDynamoDbInboxEmailLink } from "@packages/inbox-store"; + +const inboxEmailLinksTable = requireEnv("DYNAMODB_INBOX_EMAIL_LINKS_TABLE"); +const contentBucketName = requireEnv("CONTENT_BUCKET_NAME"); +const imagesCdnBaseUrl = requireEnv("IMAGES_CDN_BASE_URL"); + +const s3Client = new S3Client({}); +const dynamoClient = createDynamoDocumentClient(); +const logger = HutchLogger.from(consoleLogger); +const logError = (message: string, error?: Error) => + logger.error( + JSON.stringify({ + level: "ERROR", + timestamp: new Date().toISOString(), + message, + stack: error?.stack, + }), + ); +const logInfo = (message: string) => + logger.info( + JSON.stringify({ + level: "INFO", + timestamp: new Date().toISOString(), + message, + }), + ); + +// The same SSRF-guarded crawlFetch the save pipeline uses: every connect and +// redirect hop runs isBlockedIpAddress, so a link that DNS-rebinds to a private +// or metadata address is refused at connect time. +const crawlFetch = initCrawlFetch({ + fetch: globalThis.fetch, + personas: CRAWL_PERSONAS, + isBlocked: isBlockedIpAddress, +}); +const siteRules = [ + theInformationSiteRules, + mediumSiteRules, + linkedinSiteRules, + initXTwitterSiteRules({ crawlFetch, logError }), +]; +const crawlArticle = initCrawlArticle({ crawlFetch, siteRules, logError, logInfo }); +const { parseHtml } = initReadabilityParser({ + crawlArticle, + siteRules, + logError, +}); +const fetchThumbnailImage = initFetchThumbnailImage({ crawlFetch, logError, logInfo }); +const { putImageObject } = initS3PutImageObject({ client: s3Client, bucketName: contentBucketName }); +// A preview keeps only the article metadata and discards the body, so media +// rewriting is a no-op here; the lead-image upload to the content-bucket CDN is +// the only media side effect a preview needs. +const finalizeArticle = initFinalizeArticle({ + parseHtml, + downloadMedia: async () => [], + processContent: async ({ html }) => html, + fetchThumbnailImage, + putImageObject, + imagesCdnBaseUrl, +}); +const crawlAndFinalize = initCrawlAndFinalizeArticle({ crawlArticle, finalizeArticle }); + +const inboxEmailLinkStore = initDynamoDbInboxEmailLink({ + client: dynamoClient, + tableName: inboxEmailLinksTable, +}); + +export const handler = initCrawlEmailLinkPreviewHandler({ + crawlAndFinalize, + setLinkOutcome: inboxEmailLinkStore.setLinkOutcome, + logger, +}); diff --git a/projects/inbox/src/runtime/domain/inbox/crawl-email-link-preview-handler.test.ts b/projects/inbox/src/runtime/domain/inbox/crawl-email-link-preview-handler.test.ts new file mode 100644 index 000000000..e61a78bf2 --- /dev/null +++ b/projects/inbox/src/runtime/domain/inbox/crawl-email-link-preview-handler.test.ts @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import type { CrawlArticle } from "@packages/crawl-article"; +import { + type CrawlAndFinalizeArticle, + type CrawlAndFinalizeResult, + type FinalizeArticle, + initCrawlAndFinalizeArticle, +} from "@packages/finalize-article"; +import { HutchLogger, noopLogger } from "@packages/hutch-logger"; +import { EmailLinkOrdinalSchema, type InboxEmailLinkStore } from "@packages/domain/inbox"; +import { UserIdSchema } from "@packages/domain/user"; +import { buildLambdaContext } from "@packages/test-fixtures/lambda-context"; +import { initInMemoryInboxEmailLink } from "@packages/test-fixtures/providers/inbox-email"; +import { buildSqsEvent } from "@packages/test-fixtures/sqs"; +import { initCrawlEmailLinkPreviewHandler } from "./crawl-email-link-preview-handler"; + +const USER = UserIdSchema.parse("00000000000000000000000000000001"); +const RAM = "2026-06-24T09:00:00.000Z#"; + +function commandBody(over: Partial<{ url: string; ordinal: string }> = {}): string { + return JSON.stringify({ + detail: { + userId: USER, + receivedAtMessageId: RAM, + ordinal: over.ordinal ?? "0000", + url: over.url ?? "https://example.com/post", + }, + }); +} + +function fetched( + metadata: { title: string; siteName: string; excerpt: string; imageUrl?: string }, +): CrawlAndFinalizeResult { + return { + status: "fetched", + bodyHash: "hash", + article: { + html: "

body that is discarded

", + metadata: { + wordCount: 100, + estimatedReadTime: 1, + ...metadata, + }, + }, + }; +} + +async function seedPending(store: InboxEmailLinkStore, ordinal = "0000") { + await store.putLink({ + userId: USER, + receivedAtMessageId: RAM, + ordinal: EmailLinkOrdinalSchema.parse(ordinal), + url: "https://example.com/post", + status: "pending", + title: undefined, + excerpt: undefined, + siteName: undefined, + imageUrl: undefined, + failureReason: undefined, + }); +} + +function makeHandler(deps: { + crawlAndFinalize: CrawlAndFinalizeArticle; + setLinkOutcome: InboxEmailLinkStore["setLinkOutcome"]; +}) { + const handler = initCrawlEmailLinkPreviewHandler({ + crawlAndFinalize: deps.crawlAndFinalize, + setLinkOutcome: deps.setLinkOutcome, + logger: HutchLogger.from(noopLogger), + }); + return (body: string) => handler(buildSqsEvent([{ messageId: "rec-1", body }]), buildLambdaContext(), () => {}); +} + +async function getLink(store: InboxEmailLinkStore, ordinal = "0000") { + const link = await store.getLink({ + userId: USER, + receivedAtMessageId: RAM, + ordinal: EmailLinkOrdinalSchema.parse(ordinal), + }); + assert(link, "expected the seeded link to resolve"); + return link; +} + +describe("initCrawlEmailLinkPreviewHandler", () => { + it("stamps a crawled preview from the fetched metadata and discards the body", async () => { + const store = initInMemoryInboxEmailLink(); + await seedPending(store); + const run = makeHandler({ + crawlAndFinalize: async () => + fetched({ + title: "A title", + siteName: "Example", + excerpt: "An excerpt", + imageUrl: "https://cdn.test/x.jpg", + }), + setLinkOutcome: store.setLinkOutcome, + }); + + const result = await run(commandBody()); + + assert(result); + expect(result.batchItemFailures).toHaveLength(0); + const link = await getLink(store); + expect(link.status).toBe("crawled"); + expect(link.title).toBe("A title"); + expect(link.siteName).toBe("Example"); + expect(link.excerpt).toBe("An excerpt"); + expect(link.imageUrl).toBe("https://cdn.test/x.jpg"); + }); + + it("maps a failed crawl to a failed preview and ACKs the record", async () => { + const store = initInMemoryInboxEmailLink(); + await seedPending(store); + const run = makeHandler({ + crawlAndFinalize: async () => ({ status: "failed", reason: "crawl-failed" }), + setLinkOutcome: store.setLinkOutcome, + }); + + const result = await run(commandBody()); + + assert(result); + expect(result.batchItemFailures).toHaveLength(0); + const link = await getLink(store); + expect(link.status).toBe("failed"); + expect(link.failureReason).toBe("crawl-failed"); + }); + + it("maps an unsupported crawl to a failed preview", async () => { + const store = initInMemoryInboxEmailLink(); + await seedPending(store); + const run = makeHandler({ + crawlAndFinalize: async () => ({ status: "unsupported", reason: "pdf" }), + setLinkOutcome: store.setLinkOutcome, + }); + + await run(commandBody()); + + expect((await getLink(store)).failureReason).toBe("pdf"); + }); + + it("maps a not-modified result to a failed preview for totality", async () => { + const store = initInMemoryInboxEmailLink(); + await seedPending(store); + const run = makeHandler({ + crawlAndFinalize: async () => ({ status: "not-modified" }), + setLinkOutcome: store.setLinkOutcome, + }); + + await run(commandBody()); + + expect((await getLink(store)).failureReason).toBe("not-modified"); + }); + + it("maps a permanently-dead (404) link to a failed preview with the not-found reason and ACKs the record", async () => { + const store = initInMemoryInboxEmailLink(); + await seedPending(store); + const run = makeHandler({ + crawlAndFinalize: async () => ({ status: "not-found", httpStatus: 404 }), + setLinkOutcome: store.setLinkOutcome, + }); + + const result = await run(commandBody()); + + assert(result); + expect(result.batchItemFailures).toHaveLength(0); + const link = await getLink(store); + expect(link.status).toBe("failed"); + expect(link.failureReason).toBe("not-found"); + }); + + it("fails the record for a malformed command envelope", async () => { + const store = initInMemoryInboxEmailLink(); + const run = makeHandler({ + crawlAndFinalize: async () => fetched({ title: "x", siteName: "y", excerpt: "z" }), + setLinkOutcome: store.setLinkOutcome, + }); + + const result = await run(JSON.stringify({ detail: { wrong: "shape" } })); + + assert(result); + expect(result.batchItemFailures).toEqual([{ itemIdentifier: "rec-1" }]); + }); + + it("fails the record when the outcome write throws", async () => { + const run = makeHandler({ + crawlAndFinalize: async () => fetched({ title: "x", siteName: "y", excerpt: "z" }), + setLinkOutcome: async () => { + throw new Error("ddb unavailable"); + }, + }); + + const result = await run(commandBody()); + + assert(result); + expect(result.batchItemFailures).toEqual([{ itemIdentifier: "rec-1" }]); + }); + + it("blocks an SSRF URL via the inherited guard, never fetching it (unsafe-url)", async () => { + const store = initInMemoryInboxEmailLink(); + await seedPending(store); + let crawlCalled = false; + const crawlArticle: CrawlArticle = async () => { + crawlCalled = true; + throw new Error("must not fetch an SSRF URL"); + }; + const finalizeArticle: FinalizeArticle = async () => { + throw new Error("must not finalize an SSRF URL"); + }; + const run = makeHandler({ + crawlAndFinalize: initCrawlAndFinalizeArticle({ crawlArticle, finalizeArticle }), + setLinkOutcome: store.setLinkOutcome, + }); + + const result = await run(commandBody({ url: "http://169.254.169.254/latest/meta-data" })); + + assert(result); + expect(result.batchItemFailures).toHaveLength(0); + expect(crawlCalled).toBe(false); + const link = await getLink(store); + expect(link.status).toBe("failed"); + expect(link.failureReason).toBe("unsafe-url"); + }); +}); diff --git a/projects/inbox/src/runtime/domain/inbox/crawl-email-link-preview-handler.ts b/projects/inbox/src/runtime/domain/inbox/crawl-email-link-preview-handler.ts new file mode 100644 index 000000000..871bd108a --- /dev/null +++ b/projects/inbox/src/runtime/domain/inbox/crawl-email-link-preview-handler.ts @@ -0,0 +1,92 @@ +import type { + Handler, + SQSBatchItemFailure, + SQSBatchResponse, + SQSEvent, +} from "aws-lambda"; +import { CrawlEmailLinkPreview } from "@packages/hutch-infra-components"; +import type { HutchLogger } from "@packages/hutch-logger"; +import { EmailLinkOrdinalSchema, type InboxEmailLinkStore } from "@packages/domain/inbox"; +import { UserIdSchema } from "@packages/domain/user"; +import type { CrawlAndFinalizeArticle } from "@packages/finalize-article"; + +/** + * Consumes one `CrawlEmailLinkPreview` per message and crawls a preview of the + * single URL WITHOUT saving it to the reading queue: it keeps only the metadata + * and discards the article body, so nothing lands in the articles / user-articles + * tables. A dead/blocked/paywalled link is an expected `failed` preview (the SQS + * record is ACKed); only a genuine store-write fault or a malformed envelope + * fails the record to its DLQ. The SSRF guard is inherited from + * `crawlAndFinalize`, which fails closed on localhost/metadata/private-range URLs + * before any network call. + */ +export function initCrawlEmailLinkPreviewHandler(deps: { + crawlAndFinalize: CrawlAndFinalizeArticle; + setLinkOutcome: InboxEmailLinkStore["setLinkOutcome"]; + logger: HutchLogger; +}): Handler { + const { crawlAndFinalize, setLinkOutcome, logger } = deps; + + return async (event): Promise => { + const batchItemFailures: SQSBatchItemFailure[] = []; + + for (const record of event.Records) { + try { + const envelope = JSON.parse(record.body); + const parsed = CrawlEmailLinkPreview.detailSchema.safeParse(envelope.detail); + if (!parsed.success) { + logger.error("[crawl-email-link-preview] malformed command", { + messageId: record.messageId, + }); + batchItemFailures.push({ itemIdentifier: record.messageId }); + continue; + } + const userId = UserIdSchema.parse(parsed.data.userId); + const ordinal = EmailLinkOrdinalSchema.parse(parsed.data.ordinal); + const { receivedAtMessageId, url } = parsed.data; + + const result = await crawlAndFinalize({ url }); + if (result.status === "fetched") { + const { metadata } = result.article; + await setLinkOutcome({ + userId, + receivedAtMessageId, + ordinal, + outcome: { + status: "crawled", + title: metadata.title, + excerpt: metadata.excerpt, + siteName: metadata.siteName, + imageUrl: metadata.imageUrl, + }, + }); + logger.info("[crawl-email-link-preview] crawled", { receivedAtMessageId, ordinal }); + continue; + } + const failureReason = + result.status === "not-modified" || result.status === "not-found" + ? result.status + : result.reason; + await setLinkOutcome({ + userId, + receivedAtMessageId, + ordinal, + outcome: { status: "failed", failureReason }, + }); + logger.info("[crawl-email-link-preview] preview unavailable", { + receivedAtMessageId, + ordinal, + failureReason, + }); + } catch (error) { + logger.error("[crawl-email-link-preview] record failed", { + messageId: record.messageId, + error, + }); + batchItemFailures.push({ itemIdentifier: record.messageId }); + } + } + + return { batchItemFailures }; + }; +} diff --git a/projects/inbox/src/runtime/domain/inbox/email-content-id.test.ts b/projects/inbox/src/runtime/domain/inbox/email-content-id.test.ts new file mode 100644 index 000000000..2f177394f --- /dev/null +++ b/projects/inbox/src/runtime/domain/inbox/email-content-id.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { UserIdSchema } from "@packages/domain/user"; +import { emailContentResourceId } from "./email-content-id"; + +const RECEIVED_AT_MESSAGE_ID = "2026-06-24T09:00:00.000Z#"; + +describe("emailContentResourceId", () => { + it("derives different content keys for two users that share a receivedAtMessageId", () => { + const alice = UserIdSchema.parse("00000000000000000000000000000001"); + const bob = UserIdSchema.parse("00000000000000000000000000000002"); + + const aliceKey = emailContentResourceId({ + userId: alice, + receivedAtMessageId: RECEIVED_AT_MESSAGE_ID, + }).toS3ContentKey(); + const bobKey = emailContentResourceId({ + userId: bob, + receivedAtMessageId: RECEIVED_AT_MESSAGE_ID, + }).toS3ContentKey(); + + // Same sender Message-ID at the same receipt instant must NOT collide across + // users: each body is partitioned by its owner just like the row is. + assert.notEqual(aliceKey, bobKey); + assert.match(aliceKey, /00000000000000000000000000000001/); + }); + + it("is stable for one user and sort key so the read resolves the write's key", () => { + const user = UserIdSchema.parse("00000000000000000000000000000001"); + + const write = emailContentResourceId({ + userId: user, + receivedAtMessageId: RECEIVED_AT_MESSAGE_ID, + }).toS3ContentKey(); + const read = emailContentResourceId({ + userId: user, + receivedAtMessageId: RECEIVED_AT_MESSAGE_ID, + }).toS3ContentKey(); + + assert.equal(write, read); + }); +}); diff --git a/projects/inbox/src/runtime/domain/inbox/email-content-id.ts b/projects/inbox/src/runtime/domain/inbox/email-content-id.ts new file mode 100644 index 000000000..430fa6f24 --- /dev/null +++ b/projects/inbox/src/runtime/domain/inbox/email-content-id.ts @@ -0,0 +1,25 @@ +import { ArticleResourceUniqueId } from "@packages/article-resource-unique-id"; +import type { UserId } from "@packages/domain/user"; + +/** + * The resource id a received email's sanitized body is stored under in the + * content bucket. Scoped by the owning `userId` exactly like the `inbox_emails` + * row is partitioned, so two users whose rows happen to share a + * `receivedAtMessageId` (same sender Message-ID at the same receipt instant) + * never read or overwrite each other's private body. Both segments are + * percent-encoded into single `email://inbox//` path segments so the + * sort key's `#`/`<`/`>` characters can't be mistaken for a URL fragment (which + * `normalizeUrl` would drop), keeping the id 1:1 with the row. Shared by the + * receive path (write) and the detail page (read) so both resolve the same S3 + * key. + */ +export function emailContentResourceId(input: { + userId: UserId; + receivedAtMessageId: string; +}): ArticleResourceUniqueId { + return ArticleResourceUniqueId.parse( + `email://inbox/${encodeURIComponent(input.userId)}/${encodeURIComponent( + input.receivedAtMessageId, + )}`, + ); +} diff --git a/projects/inbox/src/runtime/domain/inbox/extract-email-links-handler.test.ts b/projects/inbox/src/runtime/domain/inbox/extract-email-links-handler.test.ts new file mode 100644 index 000000000..4207590db --- /dev/null +++ b/projects/inbox/src/runtime/domain/inbox/extract-email-links-handler.test.ts @@ -0,0 +1,229 @@ +import assert from "node:assert/strict"; +import { HutchLogger, noopLogger } from "@packages/hutch-logger"; +import { + type EmailLinkOrdinal, + type InboxEmailEntry, + InboxAddressSchema, + MessageIdSchema, + type ParseEmailResult, +} from "@packages/domain/inbox"; +import { type UserId, UserIdSchema } from "@packages/domain/user"; +import { buildLambdaContext } from "@packages/test-fixtures/lambda-context"; +import { initInMemoryInboxEmailLink } from "@packages/test-fixtures/providers/inbox-email"; +import { buildSqsEvent } from "@packages/test-fixtures/sqs"; +import { initExtractEmailLinksHandler } from "./extract-email-links-handler"; + +const USER = UserIdSchema.parse("00000000000000000000000000000001"); +const RECEIVED_AT = "2026-06-24T09:00:00.000Z"; +const RAM = `${RECEIVED_AT}#`; +const RAW_KEY = "inbound/ses-msg-1"; + +function makeEmail(overrides: Partial = {}): InboxEmailEntry { + return { + userId: USER, + receivedAtMessageId: RAM, + messageId: MessageIdSchema.parse(""), + recipientAddress: InboxAddressSchema.parse("in-3f9a2c@read.place"), + senderEmail: "news@example.com", + subject: "Digest", + status: "received", + receivedAt: RECEIVED_AT, + rawEmailS3Key: RAW_KEY, + bodyS3Key: "content/m/content.html", + ...overrides, + }; +} + +function parsedOk(html: string): ParseEmailResult { + return { + ok: true, + email: { + from: "news@example.com", + subject: "Digest", + text: "", + html, + messageId: MessageIdSchema.parse(""), + receivedAt: RECEIVED_AT, + inlineImages: [], + }, + }; +} + +function eventBody(over: Partial<{ userId: string; receivedAtMessageId: string }> = {}): string { + return JSON.stringify({ + detail: { + userId: over.userId ?? USER, + receivedAtMessageId: over.receivedAtMessageId ?? RAM, + recipientAddress: "in-3f9a2c@read.place", + }, + }); +} + +function makeHarness(opts?: { + getEmail?: (input: { userId: UserId; receivedAtMessageId: string }) => Promise; + readRawEmail?: (s3Key: string) => Promise; + parseEmail?: () => Promise; + derivedHtml?: string; + maxLinks?: number; +}) { + const linkStore = initInMemoryInboxEmailLink(); + const published: { ordinal: EmailLinkOrdinal; url: string }[] = []; + const alerts: { found: number }[] = []; + + const handler = initExtractEmailLinksHandler({ + getEmail: opts?.getEmail ?? (async () => makeEmail()), + readRawEmail: opts?.readRawEmail ?? (async () => Buffer.from("raw eml")), + parseEmail: opts?.parseEmail ?? (async () => parsedOk("

body

")), + deriveSanitizedBody: () => opts?.derivedHtml ?? "", + putLink: linkStore.putLink, + putLinksMeta: linkStore.putLinksMeta, + publishCrawlPreview: async ({ ordinal, url }) => { + published.push({ ordinal, url }); + }, + alertTruncated: async ({ found }) => { + alerts.push({ found }); + }, + logger: HutchLogger.from(noopLogger), + maxLinks: opts?.maxLinks ?? 200, + }); + + const run = (body: string) => + handler(buildSqsEvent([{ messageId: "rec-1", body }]), buildLambdaContext(), () => {}); + + return { linkStore, published, alerts, run }; +} + +describe("initExtractEmailLinksHandler", () => { + it("writes one pending row per link and fans out a crawl command for each", async () => { + const harness = makeHarness({ + derivedHtml: "https://a.test/x https://b.test/y https://c.test/z", + }); + + const result = await harness.run(eventBody()); + + assert(result); + expect(result.batchItemFailures).toHaveLength(0); + const { links, meta } = await harness.linkStore.listLinksByEmail({ + userId: USER, + receivedAtMessageId: RAM, + }); + expect(links.map((l) => [l.ordinal, l.url, l.status])).toEqual([ + ["0000", "https://a.test/x", "pending"], + ["0001", "https://b.test/y", "pending"], + ["0002", "https://c.test/z", "pending"], + ]); + // Meta is always written once extraction finishes (the "extraction ran" + // barrier the detail view polls against); only `truncated` differs. + expect(meta).toEqual({ truncated: false }); + expect(harness.published).toEqual([ + { ordinal: "0000", url: "https://a.test/x" }, + { ordinal: "0001", url: "https://b.test/y" }, + { ordinal: "0002", url: "https://c.test/z" }, + ]); + expect(harness.alerts).toHaveLength(0); + }); + + it("caps the fan-out, writes a truncated meta item, and raises one alert", async () => { + const harness = makeHarness({ + derivedHtml: "https://a.test/x https://b.test/y https://c.test/z", + maxLinks: 2, + }); + + const result = await harness.run(eventBody()); + + assert(result); + expect(result.batchItemFailures).toHaveLength(0); + const { links, meta } = await harness.linkStore.listLinksByEmail({ + userId: USER, + receivedAtMessageId: RAM, + }); + expect(links).toHaveLength(2); + expect(meta).toEqual({ truncated: true }); + expect(harness.published).toHaveLength(2); + expect(harness.alerts).toEqual([{ found: 3 }]); + }); + + it("skips an email that is not in the received state", async () => { + const harness = makeHarness({ + getEmail: async () => makeEmail({ status: "unparsed", bodyS3Key: undefined }), + derivedHtml: "https://a.test/x", + }); + + await harness.run(eventBody()); + + const { links } = await harness.linkStore.listLinksByEmail({ + userId: USER, + receivedAtMessageId: RAM, + }); + expect(links).toHaveLength(0); + expect(harness.published).toHaveLength(0); + }); + + it("skips when the email row is not yet visible", async () => { + const harness = makeHarness({ getEmail: async () => undefined, derivedHtml: "https://a.test/x" }); + + const result = await harness.run(eventBody()); + + assert(result); + expect(result.batchItemFailures).toHaveLength(0); + expect(harness.published).toHaveLength(0); + }); + + it("retries when the raw .eml is not yet readable", async () => { + const harness = makeHarness({ readRawEmail: async () => undefined }); + + const result = await harness.run(eventBody()); + + assert(result); + expect(result.batchItemFailures).toEqual([{ itemIdentifier: "rec-1" }]); + }); + + it("acks (no rows) when the raw no longer parses", async () => { + const harness = makeHarness({ parseEmail: async () => ({ ok: false, reason: "unparseable" }) }); + + const result = await harness.run(eventBody()); + + assert(result); + expect(result.batchItemFailures).toHaveLength(0); + expect(harness.published).toHaveLength(0); + }); + + it("fails the record for a malformed event envelope", async () => { + const harness = makeHarness({ derivedHtml: "https://a.test/x" }); + + const result = await harness.run(JSON.stringify({ detail: { wrong: "shape" } })); + + assert(result); + expect(result.batchItemFailures).toEqual([{ itemIdentifier: "rec-1" }]); + }); + + it("fails the record when a dependency throws, so SQS redelivers it", async () => { + const harness = makeHarness({ + getEmail: async () => { + throw new Error("dynamo down"); + }, + }); + + const result = await harness.run(eventBody()); + + assert(result); + expect(result.batchItemFailures).toEqual([{ itemIdentifier: "rec-1" }]); + expect(harness.published).toHaveLength(0); + }); + + it("is idempotent under re-delivery: no duplicate rows, re-publishes the fan-out", async () => { + const harness = makeHarness({ derivedHtml: "https://a.test/x https://b.test/y" }); + + await harness.run(eventBody()); + await harness.run(eventBody()); + + const { links, meta } = await harness.linkStore.listLinksByEmail({ + userId: USER, + receivedAtMessageId: RAM, + }); + expect(links).toHaveLength(2); + // Meta is an idempotent overwrite — re-delivery leaves a single barrier row. + expect(meta).toEqual({ truncated: false }); + expect(harness.published).toHaveLength(4); + }); +}); diff --git a/projects/inbox/src/runtime/domain/inbox/extract-email-links-handler.ts b/projects/inbox/src/runtime/domain/inbox/extract-email-links-handler.ts new file mode 100644 index 000000000..86426eca8 --- /dev/null +++ b/projects/inbox/src/runtime/domain/inbox/extract-email-links-handler.ts @@ -0,0 +1,165 @@ +import type { + Handler, + SQSBatchItemFailure, + SQSBatchResponse, + SQSEvent, +} from "aws-lambda"; +import { EmailReceivedEvent } from "@packages/hutch-infra-components"; +import type { HutchLogger } from "@packages/hutch-logger"; +import { + capEmailLinks, + type EmailLinkOrdinal, + formatEmailLinkOrdinal, + type InboxEmailLinkStore, + type InboxEmailStore, + type ParseEmailResult, + type ParsedEmailInlineImage, +} from "@packages/domain/inbox"; +import { extractUrls } from "@packages/domain/import-session"; +import { type UserId, UserIdSchema } from "@packages/domain/user"; + +/** + * Consumes `EmailReceivedEvent` and turns the links found inside the email into + * `pending` preview rows, then fans out one `CrawlEmailLinkPreview` command per + * link (mirroring how /queue fans each import URL into its own SaveLinkCommand). + * + * The body is RE-DERIVED from the immutable raw `.eml` on every run — read raw → + * re-parse → re-sanitize — so a future parse/sanitize change applies to + * extraction too, never a body sanitized by stale logic. The per-email soft cap + * bounds the fan-out; a truncated email still delivers the first N previews (the + * working path) while writing a truncated meta item and raising a DLQ alert. + */ +export function initExtractEmailLinksHandler(deps: { + getEmail: InboxEmailStore["getEmail"]; + readRawEmail: (s3Key: string) => Promise; + parseEmail: (input: { raw: Buffer; receivedAt: string }) => Promise; + deriveSanitizedBody: (input: { html: string; inlineImages: ParsedEmailInlineImage[] }) => string; + putLink: InboxEmailLinkStore["putLink"]; + putLinksMeta: InboxEmailLinkStore["putLinksMeta"]; + publishCrawlPreview: (input: { + userId: UserId; + receivedAtMessageId: string; + ordinal: EmailLinkOrdinal; + url: string; + }) => Promise; + alertTruncated: (input: { + userId: UserId; + receivedAtMessageId: string; + found: number; + }) => Promise; + logger: HutchLogger; + maxLinks: number; +}): Handler { + const { + getEmail, + readRawEmail, + parseEmail, + deriveSanitizedBody, + putLink, + putLinksMeta, + publishCrawlPreview, + alertTruncated, + logger, + maxLinks, + } = deps; + + return async (event): Promise => { + const batchItemFailures: SQSBatchItemFailure[] = []; + + for (const record of event.Records) { + try { + const envelope = JSON.parse(record.body); + const parsed = EmailReceivedEvent.detailSchema.safeParse(envelope.detail); + if (!parsed.success) { + logger.error("[extract-email-links] malformed event", { messageId: record.messageId }); + batchItemFailures.push({ itemIdentifier: record.messageId }); + continue; + } + const userId = UserIdSchema.parse(parsed.data.userId); + const { receivedAtMessageId } = parsed.data; + + const email = await getEmail({ userId, receivedAtMessageId }); + if (email === undefined || email.status !== "received") { + // Only `received` emails have a renderable body to extract from; + // rejected/unparsed rows (and a row not yet visible) are skipped. + logger.info("[extract-email-links] nothing to extract", { + receivedAtMessageId, + status: email?.status, + }); + continue; + } + + const raw = await readRawEmail(email.rawEmailS3Key); + if (raw === undefined) { + // S3 can be eventually consistent at receipt; retry. + logger.warn("[extract-email-links] raw .eml not yet readable, retrying", { + s3Key: email.rawEmailS3Key, + }); + batchItemFailures.push({ itemIdentifier: record.messageId }); + continue; + } + + const parsedEmail = await parseEmail({ raw, receivedAt: email.receivedAt }); + if (!parsedEmail.ok) { + // A body that does not parse has no links to extract. + logger.warn("[extract-email-links] raw no longer parseable", { receivedAtMessageId }); + continue; + } + + const sanitizedHtml = deriveSanitizedBody({ + html: parsedEmail.email.html, + inlineImages: parsedEmail.email.inlineImages, + }); + const extracted = extractUrls(Buffer.from(sanitizedHtml, "utf8")); + const { urls, truncated } = capEmailLinks(extracted, { maxLinks }); + + for (const [index, url] of urls.entries()) { + const ordinal = formatEmailLinkOrdinal(index); + // Put the pending row BEFORE publishing so the Articles tab shows N + // pending cards immediately; a re-delivery hits the conditional put as + // a no-op duplicate, then re-publishes (the crawl consumer is idempotent). + await putLink({ + userId, + receivedAtMessageId, + ordinal, + url, + status: "pending", + title: undefined, + excerpt: undefined, + siteName: undefined, + imageUrl: undefined, + failureReason: undefined, + }); + await publishCrawlPreview({ userId, receivedAtMessageId, ordinal, url }); + } + + // Write the per-email meta row LAST, after every link row is in place, so + // its presence is an "extraction finished" barrier: the detail view reads + // no-meta as "still extracting, keep polling" and meta-present-with-zero-rows + // as the genuinely terminal "no links found" — never collapsing the two. + await putLinksMeta({ userId, receivedAtMessageId, meta: { truncated } }); + + if (truncated) { + await alertTruncated({ userId, receivedAtMessageId, found: extracted.totalFound }); + logger.error("[extract-email-links] link cap hit, truncated", { + receivedAtMessageId, + found: extracted.totalFound, + kept: urls.length, + }); + } + logger.info("[extract-email-links] extracted", { + receivedAtMessageId, + links: urls.length, + }); + } catch (error) { + logger.error("[extract-email-links] record failed", { + messageId: record.messageId, + error, + }); + batchItemFailures.push({ itemIdentifier: record.messageId }); + } + } + + return { batchItemFailures }; + }; +} diff --git a/projects/inbox/src/runtime/domain/inbox/receive-email-handler.test.ts b/projects/inbox/src/runtime/domain/inbox/receive-email-handler.test.ts new file mode 100644 index 000000000..f4e985e68 --- /dev/null +++ b/projects/inbox/src/runtime/domain/inbox/receive-email-handler.test.ts @@ -0,0 +1,389 @@ +import assert from "node:assert/strict"; +import { HutchLogger, noopLogger } from "@packages/hutch-logger"; +import { + DEFAULT_INBOX_ALIAS, + type InboxEmailStore, + MessageIdSchema, + type ParseEmailResult, +} from "@packages/domain/inbox"; +import { type UserId, UserIdSchema } from "@packages/domain/user"; +import { buildLambdaContext } from "@packages/test-fixtures/lambda-context"; +import { initInMemoryInboxAddress } from "@packages/test-fixtures/providers/inbox-address"; +import { initInMemoryInboxEmail } from "@packages/test-fixtures/providers/inbox-email"; +import { buildSqsEvent } from "@packages/test-fixtures/sqs"; +import { initReceiveEmailHandler } from "./receive-email-handler"; +import type { StoreEmailBody } from "./store-email-body"; + +const OWNER = UserIdSchema.parse("00000000000000000000000000000001"); +const SECOND = UserIdSchema.parse("00000000000000000000000000000002"); +const UNROUTED = UserIdSchema.parse("__unrouted__"); +const RECEIVED_AT = "2026-06-24T09:00:00.000Z"; +const RAW_KEY = "inbound/ses-msg-1"; + +function sesNotification(recipients: string[]): string { + return JSON.stringify({ + mail: { messageId: "ses-msg-1" }, + receipt: { + timestamp: RECEIVED_AT, + recipients, + action: { objectKey: RAW_KEY }, + }, + }); +} + +function parsedOk(): ParseEmailResult { + return { + ok: true, + email: { + from: "news@example.com", + subject: "Digest", + text: "text", + html: "

hi

", + messageId: MessageIdSchema.parse(""), + receivedAt: RECEIVED_AT, + inlineImages: [], + }, + }; +} + +function makeHarness(opts?: { + parseEmail?: () => Promise; + storeBody?: StoreEmailBody; + maxEmailBytes?: number; +}) { + const addressStore = initInMemoryInboxAddress({ now: () => new Date() }); + const emailStore = initInMemoryInboxEmail(); + const rawMap = new Map(); + const published: { detail: { receivedAtMessageId: string; userId: string } }[] = []; + + const handler = initReceiveEmailHandler({ + readRawEmail: async (key) => rawMap.get(key), + findByAddress: addressStore.findByAddress, + putEmail: emailStore.putEmail, + parseEmail: opts?.parseEmail ?? (async () => parsedOk()), + storeBody: opts?.storeBody ?? (async () => "content/email/content.html"), + publishEvent: async (_event, detail) => { + published.push({ detail: detail as { receivedAtMessageId: string; userId: string } }); + }, + logger: HutchLogger.from(noopLogger), + maxEmailBytes: opts?.maxEmailBytes ?? 20 * 1024 * 1024, + }); + + const runMany = (recipients: string[]) => + handler( + buildSqsEvent([{ messageId: "rec-1", body: sesNotification(recipients) }]), + buildLambdaContext(), + () => {}, + ); + const run = (recipient: string) => runMany([recipient]); + + return { addressStore, emailStore, rawMap, published, handler, run, runMany }; +} + +async function listEmails(emailStore: InboxEmailStore, userId: UserId) { + const { emails } = await emailStore.listEmailsByUserId({ userId, page: 1, pageSize: 100 }); + return emails; +} + +async function mintAddress(addressStore: ReturnType) { + const entry = await addressStore.createAddress({ + userId: OWNER, + domain: "read.place", + name: DEFAULT_INBOX_ALIAS, + }); + return entry.address; +} + +describe("initReceiveEmailHandler", () => { + it("fails the record for a structurally invalid SES notification, writing no row", async () => { + const { emailStore, published, handler } = makeHarness(); + + const result = await handler( + buildSqsEvent([{ messageId: "rec-1", body: JSON.stringify({ wrong: "shape" }) }]), + buildLambdaContext(), + () => {}, + ); + + assert(result); + expect(result.batchItemFailures).toHaveLength(1); + expect(await listEmails(emailStore, OWNER)).toHaveLength(0); + expect(published).toHaveLength(0); + }); + + it("retries (no row) when the raw .eml is not yet readable", async () => { + const { addressStore, emailStore, published, run } = makeHarness(); + const address = await mintAddress(addressStore); + + const result = await run(address); + + assert(result); + expect(result.batchItemFailures).toHaveLength(1); + expect(await listEmails(emailStore, OWNER)).toHaveLength(0); + expect(published).toHaveLength(0); + }); + + it("ACKs a non-forwarding recipient (raw kept, no row, no operator page)", async () => { + const { emailStore, rawMap, published, run } = makeHarness(); + rawMap.set(RAW_KEY, Buffer.from("raw")); + + const result = await run("postmaster@read.place"); + + assert(result); + // Expected on a public catch-all MX — ACK rather than page the operator. + expect(result.batchItemFailures).toHaveLength(0); + expect(await listEmails(emailStore, OWNER)).toHaveLength(0); + expect(published).toHaveLength(0); + }); + + it("rejects an oversize email with an audit row under the owner and a DLQ failure", async () => { + const { addressStore, emailStore, rawMap, published, run } = makeHarness({ maxEmailBytes: 8 }); + const address = await mintAddress(addressStore); + rawMap.set(RAW_KEY, Buffer.from("this is definitely longer than eight bytes")); + + const result = await run(address); + + assert(result); + expect(result.batchItemFailures).toHaveLength(1); + const [row] = await listEmails(emailStore, OWNER); + expect(row.status).toBe("rejected"); + expect(row.bodyS3Key).toBeUndefined(); + expect(published).toHaveLength(0); + }); + + it("ACKs an oversize email addressed only to unknown recipients (no page)", async () => { + const { emailStore, rawMap, published, run } = makeHarness({ maxEmailBytes: 8 }); + rawMap.set(RAW_KEY, Buffer.from("this is definitely longer than eight bytes")); + + const result = await run("in-zzzzzz@read.place"); + + assert(result); + // Oversize spam to a guessed address on the public MX has no victim — audit + // under the unrouted partition and ACK rather than page the operator. + expect(result.batchItemFailures).toHaveLength(0); + const [row] = await listEmails(emailStore, UNROUTED); + expect(row.status).toBe("rejected"); + expect(published).toHaveLength(0); + }); + + it("records an unknown recipient under the unrouted partition and ACKs (no page)", async () => { + const { emailStore, rawMap, published, run } = makeHarness(); + rawMap.set(RAW_KEY, Buffer.from("raw")); + + const result = await run("in-zzzzzz@read.place"); + + assert(result); + // A guessed/mistyped address is expected on a public MX — audit, don't page. + expect(result.batchItemFailures).toHaveLength(0); + expect(await listEmails(emailStore, OWNER)).toHaveLength(0); + const [row] = await listEmails(emailStore, UNROUTED); + expect(row.status).toBe("rejected"); + expect(published).toHaveLength(0); + }); + + it("records a disabled recipient under the unrouted partition and ACKs (no page)", async () => { + const { addressStore, emailStore, rawMap, published, run } = makeHarness(); + const address = await mintAddress(addressStore); + await addressStore.disableAddress({ userId: OWNER, address }); + rawMap.set(RAW_KEY, Buffer.from("raw")); + + const result = await run(address); + + assert(result); + // Mail to a turned-off address recurs while senders still have it — audit, + // don't page. The owner opted out, so the row lands under the unrouted + // partition rather than cluttering their list with "Rejected" rows. + expect(result.batchItemFailures).toHaveLength(0); + expect(await listEmails(emailStore, OWNER)).toHaveLength(0); + const [row] = await listEmails(emailStore, UNROUTED); + expect(row.status).toBe("rejected"); + expect(row.recipientAddress).toBe(address); + expect(published).toHaveLength(0); + }); + + it("records an unparseable email as status=unparsed and fails to the DLQ", async () => { + const { addressStore, emailStore, rawMap, published, run } = makeHarness({ + parseEmail: async () => ({ ok: false, reason: "unparseable" }), + }); + const address = await mintAddress(addressStore); + rawMap.set(RAW_KEY, Buffer.from("raw")); + + const result = await run(address); + + assert(result); + expect(result.batchItemFailures).toHaveLength(1); + const [row] = await listEmails(emailStore, OWNER); + expect(row.status).toBe("unparsed"); + expect(row.bodyS3Key).toBeUndefined(); + expect(published).toHaveLength(0); + }); + + it("ACKs an unparseable email addressed only to unknown recipients (no page)", async () => { + const { emailStore, rawMap, published, run } = makeHarness({ + parseEmail: async () => ({ ok: false, reason: "unparseable" }), + }); + rawMap.set(RAW_KEY, Buffer.from("raw")); + + const result = await run("in-zzzzzz@read.place"); + + assert(result); + // Malformed spam to a guessed address is not a parser gap worth paging on — + // audit under the unrouted partition and ACK. + expect(result.batchItemFailures).toHaveLength(0); + const [row] = await listEmails(emailStore, UNROUTED); + expect(row.status).toBe("unparsed"); + expect(published).toHaveLength(0); + }); + + it("stores a received email with a body pointer and publishes EmailReceived once", async () => { + const { addressStore, emailStore, rawMap, published, run } = makeHarness({ + storeBody: async () => "content/email/content.html", + }); + const address = await mintAddress(addressStore); + rawMap.set(RAW_KEY, Buffer.from("raw")); + + const result = await run(address); + + assert(result); + expect(result.batchItemFailures).toHaveLength(0); + const [row] = await listEmails(emailStore, OWNER); + expect(row.status).toBe("received"); + expect(row.bodyS3Key).toBe("content/email/content.html"); + expect(row.senderEmail).toBe("news@example.com"); + expect(published).toHaveLength(1); + expect(published[0].detail.receivedAtMessageId).toBe(`${RECEIVED_AT}#`); + expect(published[0].detail.userId).toBe(OWNER); + }); + + it("matches a recipient case-insensitively when an MTA upper-cases the local part", async () => { + const { addressStore, emailStore, rawMap, published, run } = makeHarness(); + const address = await mintAddress(addressStore); + rawMap.set(RAW_KEY, Buffer.from("raw")); + + // Minted addresses are lowercase and the lookup is exact; an MTA that + // preserves a differently-cased local part must still reach the owner. + const result = await run(address.toUpperCase()); + + assert(result); + expect(result.batchItemFailures).toHaveLength(0); + const [row] = await listEmails(emailStore, OWNER); + expect(row.status).toBe("received"); + expect(published).toHaveLength(1); + }); + + it("persists 'unparsed' (no body, no event) and ACKs when the body sanitizes to nothing", async () => { + const { addressStore, emailStore, rawMap, published, run } = makeHarness({ + // An all-", + inlineImages: [], + }); + + // A body the sanitizer empties (only stripped tags) must NOT become a + // zero-byte object that reads back as "" and renders a blank iframe — signal + // "no body" so the caller persists `unparsed` and shows the unavailable panel. + expect(key).toBeUndefined(); + expect(writes).toHaveLength(0); + }); +}); diff --git a/projects/inbox/src/runtime/domain/inbox/store-email-body.ts b/projects/inbox/src/runtime/domain/inbox/store-email-body.ts new file mode 100644 index 000000000..09d6c6685 --- /dev/null +++ b/projects/inbox/src/runtime/domain/inbox/store-email-body.ts @@ -0,0 +1,40 @@ +import { deriveSanitizedBody } from "@packages/domain/inbox"; +import type { ParsedEmailInlineImage } from "@packages/domain/inbox"; +import type { UserId } from "@packages/domain/user"; +import { emailContentResourceId } from "./email-content-id"; + +export type StoreEmailBody = (input: { + userId: UserId; + receivedAtMessageId: string; + html: string; + inlineImages: ParsedEmailInlineImage[]; +}) => Promise; + +/** + * Turn a parsed newsletter into the safe HTML the View tab renders, and write it + * to the content bucket. Each `cid:` inline image (already resolved to an + * `email://cid/` URL by the preparser) is inlined as a `data:` URI carrying + * its bytes, then the allowlist sanitizer keeps those — and strips every remote + * image so tracking beacons can't fire. Inlining (rather than rehosting to a + * cross-origin CDN) is what lets the images render inside the View tab's + * sandboxed, opaque-origin iframe. + * + * Returns the S3 key the body was written to, or `undefined` when sanitizing + * leaves nothing renderable — a body composed entirely of tags the sanitizer + * strips wholesale (`