Skip to content

feat(email): staging-only /staging/preview/<uuid> + shared render API#171

Merged
rubenhensen merged 2 commits into
mainfrom
feat/staging-email-preview-endpoint
Jun 3, 2026
Merged

feat(email): staging-only /staging/preview/<uuid> + shared render API#171
rubenhensen merged 2 commits into
mainfrom
feat/staging-email-preview-endpoint

Conversation

@rubenhensen
Copy link
Copy Markdown
Contributor

Summary

  • Extract render_recipient_email / render_confirmation_email from send_email. Both return RenderedEmail { recipient, subject, from, reply_to, html, text } and share the template plumbing.
  • send_email now consumes those renderers; the SMTP/MIME plumbing stays where it was. No behavior change for production sends.
  • New route GET /staging/preview/<uuid> returns the rendered notification(s) cryptify would have sent for an upload. Gated on config.staging_mode() — anything else returns 404. The staging postguard-website will call this so testers can grab the download link from the page itself instead of kubectl logs cryptify | grep STAGING.

Test plan

  • cargo test --bin cryptify email:: — 27 passed (3 new: recipient render, confirmation render w/ sender, confirmation render w/o sender).
  • On a staging deploy, curl https://storage.staging.postguard.eu/staging/preview/<uuid> returns JSON for a known upload.
  • Same curl against production returns 404.

Pairs with the in-browser preview modal in the postguard-website PR.

Factor `send_email` so the per-recipient and confirmation email bodies
are produced by `render_recipient_email` / `render_confirmation_email`
(public, pure, no SMTP). `send_email` consumes those and adds the
SMTP/MIME plumbing — keeping the email layout in one place.

Add `GET /staging/preview/<uuid>`, gated on `config.staging_mode()` and
returning 404 otherwise. Response is JSON `{ recipients: [...],
confirmation: ... }` with each entry carrying `recipient`, `subject`,
`from`, `reply_to`, `html`, `text`. The staging website calls this so
developers can read what cryptify *would* have sent without scraping
log lines.
@dobby-coder
Copy link
Copy Markdown
Contributor

dobby-coder Bot commented Jun 3, 2026

Dobby has received the request! Routing to the right specialist now...

Copy link
Copy Markdown
Contributor

@dobby-coder dobby-coder Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Clean behavior-preserving extraction of render_recipient_email/render_confirmation_email; build, clippy, and tests pass. Three minor items inline: a misplaced doc comment, no endpoint-level tests on the new gated route, and a silently-skipped confirmation path that should at least log.

Rule compliance

No issues found.

Comment thread src/main.rs Outdated
@@ -972,6 +972,65 @@ fn usage(store: &State<Store>, api_key: ApiKey, email: String) -> Json<UsageResp

/// Parsed byte range derived from an HTTP `Range` header and the resource's
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] The Parsed byte range... doc comment that used to introduce struct ByteRange is now glued to StagingPreviewResponse, and ByteRange (now ~line 1034) has no doc comment. Split the block: move the staging-preview doc onto StagingPreviewResponse and put the byte-range doc back above struct ByteRange.

Comment thread src/main.rs
confirmation: Option<RenderedEmail>,
}

#[get("/staging/preview/<uuid>")]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] No tests cover /staging/preview/<uuid> itself — the three new unit tests only exercise the renderers. A Rocket local-client test for (a) 404 when staging_mode = false, (b) 404 on unknown uuid, and (c) JSON shape when staging is on would pin the only gate protecting the endpoint from leaking PII.

Comment thread src/email.rs
@@ -436,31 +512,31 @@ pub async fn send_email(
}

if state.confirm {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] Previously state.confirm && state.sender.is_none() panicked via .unwrap(); now it silently skips and only this inline comment hints at the invariant. If the invariant ever loosens, this becomes a silent missing-confirmation bug. Add a log::warn! (or log::error!) on the else/None arm so a violation is at least observable.

- Move the "Parsed byte range…" doc back above `struct ByteRange`; it
  had been displaced onto `StagingPreviewResponse` by the previous edit.
- `send_email`'s confirmation arm now logs at error level when
  `state.confirm` is set but `render_confirmation_email` returns `None`,
  instead of silently dropping the sender's copy if the invariant ever
  loosens.
- Three Rocket local-client tests for `/staging/preview/<uuid>`: 404
  when `staging_mode = false`, 404 on unknown uuid, and a happy-path
  case asserting the JSON shape (recipients + confirmation).
@rubenhensen
Copy link
Copy Markdown
Contributor Author

/dobby re review

@dobby-coder
Copy link
Copy Markdown
Contributor

dobby-coder Bot commented Jun 3, 2026

Dobby has received the request! Routing to the right specialist now...

Copy link
Copy Markdown
Contributor

@dobby-coder dobby-coder Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

All three prior findings addressed: ByteRange doc restored, confirmation None arm now log::error!s the invariant breach, and three Rocket route tests cover the staging_mode gate, unknown-uuid 404, and happy-path JSON shape. Build, clippy, and 119 tests pass.

Rule compliance

No issues found.

rubenhensen added a commit to encryption4all/postguard-website that referenced this pull request Jun 3, 2026
* feat(staging): in-page preview of the email cryptify would have sent

On staging, cryptify runs with `staging_mode = true` and logs notification
emails instead of dispatching via SMTP — handy for safety, painful for
testing because the only way to get the /download link was to scrape
container logs.

This wires up an automatic modal on the "files sent" screen that pulls
the rendered email(s) from cryptify's new `GET /staging/preview/<uuid>`
endpoint and embeds the HTML body in a sandboxed iframe. A header bar
shows From / Reply-To / Subject / To with a switcher when there are
multiple recipients (including the sender's confirmation copy). The
email body itself is rendered by cryptify, not re-mocked here, so the
two cannot drift.

The runtime gate is a new `STAGING` flag on `window.APP_CONFIG`,
injected by the postguard-ops ConfigMap from the existing
`cryptify_staging_mode` Terraform variable. Production sees no UI
change.

Requires encryption4all/cryptify#171.

* review: dedicated empty state + focus-visible on close affordances

- The "no recipients" path previously set `status = 'error'` and stuffed
  the empty-state copy into `errorMessage`, so the modal rendered
  "Could not load preview." followed by "No recipients to preview." —
  two conflicting messages. Add a distinct `'empty'` status and render
  the empty copy on its own.
- Add `:focus-visible` outlines on `.close-btn` and `.backdrop-close`
  so keyboard users get a visible focus indicator on the only two
  interactive elements outside the content body.
@rubenhensen rubenhensen merged commit 63066a1 into main Jun 3, 2026
7 checks passed
@rubenhensen rubenhensen deleted the feat/staging-email-preview-endpoint branch June 3, 2026 08:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant