## Context

See `proposal.md` — Why. The constraints that shape the approach are all pre-existing:

- The card is a self-contained HTML document rendered inside a shadow root, portrait, sized
  for a phone screen. It is not an image and cannot become one without a browser.
- `/i/{token}` is `private, no-store`, `noindex`, `no-referrer`, and the token is a bearer
  secret (add-rsvp-v1 D11). Its `generateMetadata` already carries the comment *"Guest name
  is deliberately absent from the title: it would leak into link previews."* — this change is
  that comment's other half.
- `event_card_design.assets` is keyed by the **uploaded filename**, because the same map both
  rewrites the document's references and records the design's companions. Anything stored
  there is, by construction, something the card document may point at.
- `templating.to_html` escapes the entire body and then linkifies it. It has no concept of
  markup the system itself produced.
- The web container reads runtime configuration through `web/lib/public-config.ts`; there is
  no base-URL accessor there yet, and `process.env.NEXT_PUBLIC_*` is banned by lint.
- Owner decisions from the proposal discussion: the image is uploaded, not generated; the
  tokenized route's metadata stays guest-free; the admin copy control hands out the guest's
  token link.

## Goals / Non-Goals

**Goals:**

- One preview model, computed once on the API, rendered by three surfaces.
- Previews on the tokenized route that disclose nothing a crawler could not already read
  from the public event page.
- An email preview block that survives blocked images, stripped CSS, and the plain-text part.
- No new runtime dependency, no new service, and no Alembic migration.

**Non-Goals:**

- Rasterizing the card. Ruled out in D1 below and not revisited by this change.
- Generating imagery from event data (`next/og`, Pillow compositing). Same decision.
- Per-guest preview images. Excluded by the guest-free rule in D3, not merely deferred.
- Preview metadata on admin routes, the unsubscribe route, or the cancellation route.
- Invalidating third-party unfurl caches. See the risk table.

## Decisions

### D1 — The preview image is uploaded, not derived from the card

The design team exports a 1200×630 raster alongside the card and uploads it with the design.

*Alternatives considered.* **Headless Chromium screenshot of the card** is the only faithful
option and was rejected on two counts: it adds a browser (~400MB) plus a render job and a new
failure mode to an API image that currently has neither, and — the deciding count — the card
is portrait while a preview is 1.91:1 landscape, so the faithful crop drops the couple's names
out of the frame at the one size that matters, a 200px-wide chat list row. **`next/og`
`ImageResponse`** renders from event data with no browser, but Satori supports a subset of CSS
and cannot execute the design team's document, so it would produce a *different* picture that
merely claims to represent the card. **Pillow compositing** over a card background asset is the
same objection with more code. A human choosing the crop beats all three, and the upload path
already exists.

*Consequence.* Every event needs a deliberate export step, and an event that skips it gets no
picture. That is why the degradation behaviour is specified rather than left to fall out.

### D2 — The image lives in the design's renderer config, not in its asset map

`assets` is keyed by uploaded filename and is consumed by `card_html.sanitise` as the
rewrite table. A preview image is not something the card document references, so putting it
there would (a) make it eligible for reference rewriting, (b) let a companion file named
`og.png` claim the role by accident, and (c) put it in `asset_names`, which is the list the
"you did not upload the files your card references" check reads.

So `HtmlCardConfig` gains an optional `preview_image` object — stored URL, width, height,
byte size, and content type — and the upload endpoint takes a dedicated `og_image` file
field. `config` is JSONB and the field is optional, so **no migration is required** and
existing designs read back as having no preview image.

Byte accounting is the one thing that must be wired by hand: the per-design budget check
currently sums `assets`, and the preview image has to be added to that sum explicitly.

### D3 — Tokenized previews are guest-free, and the canonical URL is the public page

`/i/{token}` emits the same title, description and image as `/e/{slug}`, and declares
`og:url` = the open event link.

The forcing constraint is that unfurling is done by a third party. When a guest pastes their
link into WhatsApp, Meta's crawler fetches the tokenized URL and caches what it finds. Nothing
in the page can prevent that fetch — `no-store` is a directive to caches that obey it, and a
crawler is not one. What the page *can* control is what the fetch is worth. Guest-free
metadata makes a crawler's view of `/i/{token}` exactly equal to its view of `/e/{slug}`, so
the only thing the crawler learns that it could not have learned publicly is that a token
exists. The canonical `og:url` means the platform attributes and caches the preview against
the public page rather than the token.

*What this does not fix.* The token itself still reaches a third party's logs and cache. That
is the irreducible cost of previewing a secret URL and is accepted, recorded here rather than
buried. The remedy if a token is known to have leaked is the same as it was before this change
— reissue it.

*Alternative considered.* Metadata on `/e/{slug}` only, leaving tokenized links bare. It
removes the residual entirely, but it also means the invitation link — the one guests actually
receive and actually forward — is the one that previews as nothing, which is the whole problem
this change exists to solve.

### D4 — The email block is composed by the system, not unfurled

Mail clients do not read Open Graph tags. Gmail, Outlook and Apple Mail render a bare URL as
a bare URL. So the email carries markup the system builds: an anchor wrapping the image,
title, date, venue and a call to action, table-based and inline-styled because email CSS
support is what it is.

This block is personalized. It goes to one inbox, no crawler is involved, and the message
already carries the guest's name and their token — so the D3 restriction does not apply here,
and applying it anyway would make the email worse for no gain.

**Placement:** the block is chrome, above the body, and wraps hand-edited messages too. An
admin who rewrote the text still gets the picture, which is what they would expect and is why
the block is not part of the editable text.

**Where it plugs in:** `to_html` gains an optional pre-rendered block parameter, inserted
after escaping rather than concatenated before it — the function escapes its whole input, so
passing markup in through `text` would emit it literally. The plain-text alternative is
untouched; the invitation URL is already in it, and `check_link_lengths` keeps working on the
same string it always did.

**Images off is the default case, not the edge case.** Most clients block remote images until
the recipient allows them, so the block is laid out to read correctly with no image at all:
title, date and venue are text, and the image carries alt text naming the event.

### D5 — One preview model, built on the API

`app/services/link_preview.py` builds the model from an event, plus an optional invitation
when the surface is allowed to personalize. It is the single producer for the meta tags, the
email block and the admin panel.

The API is the right home rather than the web app because the email is composed server-side
in Python and would otherwise need a second implementation of "what does this event's preview
say" — which is exactly how the invitation page and the compose panel would drift apart. The
preview object rides along on the existing invitation-by-token and open-event responses, so
the web app renders a view model and computes nothing, and `make client` picks up the shape.

### D6 — Image validation reads the header, adds no dependency

Accept `image/png` and `image/jpeg` only. WebP and AVIF are rejected despite being better
formats, because unfurl support for them is inconsistent across iMessage, Slack and older
Android WhatsApp builds, and a preview that silently fails on one platform is worse than a
larger file. SVG is rejected outright — no major unfurler renders it.

Dimensions come from a small stdlib header parse (PNG `IHDR`, JPEG `SOF` markers) rather than
Pillow. Two formats, fixed offsets, roughly forty lines, and it keeps the API image free of
an imaging dependency it needs for nothing else. Minimum 600×315; below that platforms fall
back to a small square thumbnail.

The byte cap is **300KB**, chosen because WhatsApp degrades to the small thumbnail above
roughly that size — the cap encodes a platform behaviour, so the constant carries that
sentence as its comment.

Aspect ratio is **warned, not enforced**. Off-ratio images crop rather than fail, and refusing
a usable image over a shape preference would send a designer back to a re-export for a
cosmetic reason.

Validation lives in `media.py` as a second entry point beside the existing `validate`, because
that module remains the only one that touches the media filesystem.

### D7 — `APP_BASE_URL` reaches the web container

Preview URLs must be absolute; Next needs `metadataBase` to make them so. `public-config.ts`
gains `appBaseUrl()` reading the un-prefixed `APP_BASE_URL`, and the compose files pass it to
the web service via `env_file:`.

A wrong or missing value produces previews that are broken in a way nothing in development
reveals — the failure mode `public-config.ts` was written to prevent, arriving by a different
door. So `assert_production_ready()` gains a check that the API's `app_base_url` is neither
the localhost default nor plain HTTP.

*Amended during implementation.* The web accessor was to fall back to the request's own
origin. It does not, for two reasons — and the weaker one was the one written here first, so
it is worth separating them.

The reason that actually decides it: the fallback is **redundant**. Every preview URL arrives
from the API already absolute, and `metadataBase` only ever resolves relative ones. So the
fallback would exist to serve a case that does not occur.

The secondary reason, stated more carefully than in the first draft: `headers()` opts a route
out of static rendering. `/e/[slug]` is *already* request-rendered today — the build reports
it as dynamic, and the route's own comment explains why (`generateStaticParams` would need the
API reachable at build time). So there is no ISR being protected here right now. What
`headers()` would do is foreclose the prerendering that same comment contemplates for the
Phase 6 load test, which is a smaller cost than originally claimed but still a real one for no
gain.

When the variable is unset, the page omits `metadataBase` entirely.

### D8 — Copying a link is an audited read, not a send

The copy control writes an audit entry and touches nothing else: no `message_job`, no
`message_log`, no `last_sent_at`. An admin taking a bearer token out of the system by hand is
worth a log line — it is the one path by which an invitation link leaves the system with no
delivery record attached to it.

The panel still shows suppression state above the control. Copying a suppressed guest's link
is allowed, because suppression governs what the system sends, not what a host may hand
someone in person, but the admin should know before they do it.

### D9 — Known preview crawlers do not count as opens

Added during implementation, after the code contradicted an assumption in this design.

`/invitations/by-token/{token}` calls `lifecycle.mark_opened` on every fetch, and the Next
page fetches it server-side while rendering. A crawler requesting `/i/{token}` therefore
increments `open_count`, moves a `PENDING` invitation to `OPENED`, and writes a history row.
Metadata alone cannot avoid this: an unfurler fetches the whole page, not just its head.

That is not an abstract concern for this change. D8 adds a copy-link control **so an admin
can paste an invitation into WhatsApp** — so the feature, used as intended, would mark the
guest's invitation opened before they had seen it.

So the web route matches the request's declared client against a list of the major
platforms' preview crawlers and, on a match, reads through a variant that does not mark the
open. The preview still renders; only the recording is skipped.

*Alternatives considered.* **Marking the open from the browser after hydration** is the more
correct answer — a crawler runs no JavaScript, so the signal becomes "a browser rendered
this", which is what the metric was always reaching for. It was not taken here because it
rewrites existing, tested lifecycle behaviour inside a change that is otherwise additive,
and it silently drops guests who block scripts. It is the right follow-up if open counts
ever need to be trusted precisely. **Accepting the miscount** was rejected because the
copy-link flow would produce it on every use.

*Known limitation, stated rather than hidden.* Client identity is self-declared, so
recognition is approximate. An unrecognised crawler is treated as a guest — which is exactly
today's behaviour, so the failure mode is the status quo rather than a regression. The list
is one constant, in one file, so extending it is a one-line change.

## Risks / Trade-offs

| Risk | Mitigation |
|---|---|
| Invitation tokens reach Meta's (and Slack's, and Apple's) crawler cache | Accepted and recorded in D3. Metadata is guest-free so the cached content is worthless; canonical `og:url` keys the platform's cache to the public page; `noindex` and `no-referrer` unchanged. A leaked token is reissued, as before. |
| A crawler fetch could be mistaken for a guest opening their invitation | Spec requires a crawler visit to change nothing recorded about the invitation. Verified by test rather than by inspection, because it is the kind of thing a later "track opens" feature would break silently. |
| Crawler traffic hits the token lookup endpoint | Same request shape and same rate limiting as any other fetch of that route; a crawler fetches once per paste, which is negligible against a 3,000-message wave. |
| Republishing a card does not refresh previews already cached by a platform | The stored image URL is content-addressed so it does change, but platforms cache by *page* URL for around a week. Documented in the user guide with the platform's own re-scrape tool as the remedy. Not solvable from our side; pretending otherwise would be worse. |
| Remote images blocked by default in most mail clients | D4 lays the block out to read correctly with no image, and the spec makes that a scenario rather than an aspiration. |
| `APP_BASE_URL` wrong in production, breaking every preview silently | D7 — production readiness check plus a request-origin fallback. |
| An admin uploads a design and forgets the preview image, and nobody notices until a guest forwards a link | The compose panel states the absence and says what to do about it; the audit entry records whether a version carried one. |
| The email gets heavier and more image-like, which reputation systems notice | One image, hosted on our own domain, inside a message that already has a plain-text alternative and a real body. Watch bounce and spam rates through the existing failure alert after the first wave. |

## Migration Plan

Additive throughout, and deliberately so: `config` is JSONB and `preview_image` is optional,
so **no Alembic migration is written**. Deploy order is API then web — the web app reads the
preview object from the API response and omits the tags when it is absent, so a web container
running ahead of the API degrades to today's behaviour rather than erroring.

Existing published designs continue to serve with no preview image until someone re-uploads
one. There is no backfill: choosing the crop is the manual step D1 accepts.

Rollback is a redeploy of the previous images. Stored preview images become unreferenced
files under `media/`, which is the same state any rolled-back card design already leaves
behind, and they are covered by the existing backup rule.

## Open Questions

- Whether the open event page should link-preview with the card image or a wider venue photo
  once a real card exists to look at. Both read from the same field, so this is a content
  decision the design team can make after the first event ships, not a structural one.
- Whether the copy-link control belongs on the guest list row as well as in the compose panel.
  Depends on how often admins actually use it; the audit log added in D8 will answer it.
