## 1. Preview image storage and validation

- [x] 1.1 Add `validate_preview_image` to `api/app/services/media.py`: accept `image/png` and `image/jpeg` only, refuse anything else with a message naming the accepted formats (design D6)
- [x] 1.2 Add a stdlib header parser for PNG `IHDR` and JPEG `SOF` dimensions in the same module — no Pillow, no new dependency (design D6)
- [x] 1.3 Enforce the 300KB byte cap and the 600×315 minimum, with refusal messages that state the file's actual size or dimensions and the limit; comment the 300KB constant with the WhatsApp thumbnail-degradation reason it encodes
- [x] 1.4 Return an aspect-ratio warning (not a refusal) when the image is not within tolerance of 1.91:1
- [x] 1.5 Store the accepted image through the existing content-addressed `save` path so it is served immutably like every other asset
- [x] 1.6 Unit-test 1.1–1.4: SVG refused, WebP refused, oversized refused, undersized refused, square image accepted with a warning, valid image accepted

## 2. Card design carries the preview image

- [x] 2.1 Add an optional `preview_image` object (url, width, height, byte size, content type) to `HtmlCardConfig` — in `config`, deliberately **not** in `assets` (design D2)
- [x] 2.2 Add an `og_image` upload field to `POST /admin/events/{event_id}/card-designs`, validated through task 1
- [x] 2.3 Include the preview image's bytes in the per-design budget sum, and make the over-budget message account for it
- [x] 2.4 Confirm the preview image is excluded from `asset_names` and from `card_html.sanitise`'s rewrite table, so it is never treated as a card reference and a companion named `og.png` cannot claim the role
- [x] 2.5 Surface the aspect-ratio warning from 1.4 in the upload response so the admin sees it
- [x] 2.6 Record whether a version carried a preview image in the `CARD_UPLOAD` audit entry
- [x] 2.7 Expose the preview image on the card design read model so the admin panel can show what is published
- [x] 2.8 Tests: upload with and without an image, rollback restores the previous version's image, budget refusal includes it, `og.png` companion does not claim the role

## 3. The preview model

- [x] 3.1 Create `api/app/services/link_preview.py` building title, description, image and canonical URL from an event, with an optional invitation for surfaces allowed to personalize (design D5)
- [x] 3.2 Compose the description from occasion, date in `Asia/Dhaka` and venue; assert no guest field can reach the guest-free variant
- [x] 3.3 Resolve absolute URLs from `app_base_url`; canonical URL is always the open event link `/e/{slug}` (design D3)
- [x] 3.4 Handle the no-image case by omitting the image rather than emitting a placeholder
- [x] 3.5 Unit-test that the guest-free variant is byte-identical for two different invitations to the same event, and that it contains no token, name, phone or email

## 4. API responses and generated client

- [x] 4.1 Add the guest-free preview object to the invitation-by-token response schema
- [x] 4.2 Add it to the open-event response schema
- [x] 4.3 Run `make client` and commit `web/lib/api/schema.d.ts`
- [x] 4.4 Map the preview into an explicit view model in `web/lib/view-models.ts` — no spreading of API responses into client props

## 5. Base URL configuration

- [x] 5.1 Add `appBaseUrl()` to `web/lib/public-config.ts` reading the un-prefixed `APP_BASE_URL`, with the request origin as fallback (design D7)
- [x] 5.2 Pass `APP_BASE_URL` to the web service in both compose overlays — via `environment:`, not `env_file:`, matching the existing deliberate rule that the frontend holds no secrets. Already present in `.env.example`
- [x] 5.3 Extend `assert_production_ready()` to refuse boot when `app_base_url` is the localhost default or a placeholder
- [x] 5.4 Test the production readiness refusal

## 6. Meta tags on the invitation routes

- [x] 6.1 Set `metadataBase` from `appBaseUrl()` so every emitted URL is absolute
- [x] 6.2 Emit Open Graph and Twitter Card tags from `generateMetadata` in `web/app/e/[slug]/page.tsx`
- [x] 6.3 Emit the same tags from `web/app/i/[token]/page.tsx`, guest-free, with `og:url` canonical to `/e/{slug}` (design D3)
- [x] 6.4 Declare `og:image:width` and `og:image:height` when an image exists; omit the image tags entirely when it does not
- [x] 6.5 Verify `noindex`, `no-referrer`, `force-dynamic` and `revalidate = 0` on the tokenized route are untouched
- [x] 6.6 Test that two tokens for the same event produce byte-identical metadata, and that no metadata field contains a token

## 6b. Preview crawlers do not count as opens (design D9)

- [x] 6b.1 Add a `?preview=1` mode to the invitation-by-token read that skips `lifecycle.mark_opened`, leaving every other part of the response identical
- [x] 6b.2 Add a known-crawler list (facebookexternalhit, WhatsApp, Twitterbot, Slackbot, Discordbot, TelegramBot, LinkedInBot, Applebot, Bingbot, Embedly, redditbot) as one exported constant so extending it is a one-line change
- [x] 6b.3 Match the incoming request's user agent in `web/app/i/[token]/page.tsx` and fetch through the non-marking mode on a match
- [x] 6b.4 Test: the non-marking mode leaves `open_count`, status and history untouched and a normal read still records the open (`test_invitation_preview.py`). The user-agent matching itself is verified against the running stack, not by unit test — `web/` has no test runner, and adding one is a larger decision than this change should make alone
- [x] 6b.5 Test that the preview still renders for a crawler — skipping the open must not skip the page

## 7. Email preview block

- [x] 7.1 Render the block as table-based, inline-styled HTML: image (with alt text naming the event), title, date, venue, and the guest's link as the anchor (design D4)
- [x] 7.2 Add an optional pre-rendered block parameter to `templating.to_html`, inserted **after** the escape-and-linkify pass so it is not emitted literally
- [x] 7.3 Lay the block out to read correctly with images blocked — title, date and venue as text, never a bare broken frame
- [x] 7.4 Leave the plain-text alternative unchanged; confirm `check_link_lengths` still runs against the same string
- [x] 7.5 Attach the block to invitation sends, including hand-edited `body_text` jobs — the block is chrome above the body, not part of the editable text
- [x] 7.6 Build the block at send time and confirm it never reaches `message_log` or an audit entry (design D4, existing PII rule)
- [x] 7.7 Tests: body escaping preserved, plain-text part contains no markup, log contains neither token nor block, event with no image produces the text-only block

## 8. Admin panel

- [x] 8.1 Build a shared preview component rendering the same model the guest receives
- [x] 8.2 Show it in `web/components/admin/InviteCompose.tsx` above the editable message
- [x] 8.3 State the absence of a preview image, and say that publishing a card design with one would add the picture
- [x] 8.4 Add a copy-to-clipboard control for the guest's `/i/{token}` link, available even when the guest has no email address
- [x] 8.5 Show suppression state above the copy control for a `do_not_contact` guest
- [x] 8.6 Add an audit action for taking an invitation link; record admin, invitation and time. Copying SHALL NOT touch `message_job`, `message_log` or `last_sent_at` (design D8)
- [x] 8.7 Test that copying leaves send state and the message log unchanged, and that the audit entry is written

## 9. Verification and documentation

- [x] 9.1 Extend `make walkthrough` to upload a design with a preview image and assert the tags appear on both routes
- [ ] 9.2 Manually unfurl a real invitation link in WhatsApp and one desktop chat client; confirm the large card renders and names no guest. **Blocked until launch** — needs a public HTTPS domain a crawler can reach; localhost cannot be unfurled. The tags, their guest-invariance and the image dimensions are verified against the running stack and by test
- [ ] 9.3 Send one real invitation to a mail account with remote images blocked; confirm the block reads correctly. **Blocked until launch** — needs a real mailbox and a configured provider (dev runs `DRY_RUN=true`). The images-off layout is covered by test
- [x] 9.4 Document the preview image export in the design-team hand-off: 1200×630, PNG or JPEG, under 300KB, names legible at thumbnail size
- [x] 9.5 Document in `docs/USER-GUIDE.md` that platforms cache unfurls for about a week and that republishing a card does not refresh them, naming the platform re-scrape tool as the remedy
- [x] 9.6 `make lint` and `make test` clean; confirm the generated-client freshness check passes
