feat(intake): notify the admin with signed action links #224

Closed
opened 2026-08-29 08:10:23 -05:00 by bermudalamb · 1 comment
Owner

Slice 3 of #220. Depends on slice 2 — there is nothing worth emailing until a draft exists.

Design: docs/superpowers/specs/2026-08-29-intake-pipeline-design.md

Scope

A new intakeDraft key in emailTemplates.ts, so the copy is editable from the settings screen like every other template, with reviewUrl in its required placeholders — a notification with no link in it still sends, still looks fine in the log, and is useless to the person who receives it, which is exactly what the required-placeholder validation exists to catch.

Action links are HMAC-signed with a new INTAKE_ACTION_SECRET over (draftId, action, expiry), verified with a timing-safe comparison, valid for 30 days.

  • Regenerate — back to state='queued' for the worker to pick up.
  • Discardstate='discarded' and the item unpublished, recoverable from the queue. A one-click destructive action reachable from an inbox should not be final.
  • Review & publish — an ordinary deep link into the admin queue, behind authentik like the rest of /admin. It carries no signature and grants nothing.

What the email deliberately cannot do

There is no publish button. The two signed actions are exactly the ones whose worst case is a wasted API call or a recoverable hide. Publishing stays a deliberate act on a screen showing the price, which is what bounds the risk taken by pricing items on arrival — see the invariant section of the design.

Failure

SMTP being down must not strand a draft. The queue, not the email, is the source of truth: a ready draft is visible and actionable in the admin whether or not its notification ever sent.

Part of #220

Slice 3 of #220. Depends on slice 2 — there is nothing worth emailing until a draft exists. **Design:** `docs/superpowers/specs/2026-08-29-intake-pipeline-design.md` ## Scope A new `intakeDraft` key in `emailTemplates.ts`, so the copy is editable from the settings screen like every other template, with `reviewUrl` in its required placeholders — a notification with no link in it still sends, still looks fine in the log, and is useless to the person who receives it, which is exactly what the required-placeholder validation exists to catch. Action links are HMAC-signed with a new `INTAKE_ACTION_SECRET` over `(draftId, action, expiry)`, verified with a timing-safe comparison, valid for 30 days. - **Regenerate** — back to `state='queued'` for the worker to pick up. - **Discard** — `state='discarded'` and the item unpublished, recoverable from the queue. A one-click destructive action reachable from an inbox should not be final. - **Review & publish** — an ordinary deep link into the admin queue, behind authentik like the rest of `/admin`. It carries no signature and grants nothing. ## What the email deliberately cannot do There is no publish button. The two signed actions are exactly the ones whose worst case is a wasted API call or a recoverable hide. Publishing stays a deliberate act on a screen showing the price, which is what bounds the risk taken by pricing items on arrival — see the invariant section of the design. ## Failure SMTP being down must not strand a draft. The queue, not the email, is the source of truth: a `ready` draft is visible and actionable in the admin whether or not its notification ever sent. Part of #220
Author
Owner

Done, merged in PR #265, and deployed.

The intakeDraft template is editable from the settings screen like every other one, with reviewUrl as its only required placeholder. The drafting worker sends it after a draft commits, fire and forget — a mail failure must never mark a draft that was written correctly as failed.

The plan made one decision this issue did not, and it changed the shape of the feature. Mail scanners and corporate link-rewriting gateways issue a GET against every URL in a message before a human sees it. A GET .../discard would therefore have fired itself on delivery, carrying a valid signature and looking entirely legitimate in the log — and nobody would have known to go and recover it. So the signed link is a safe GET that confirms and a POST that acts. It costs one extra click, and it has its own test.

The signature covers the item, the action and the expiry together, so a link cannot be replayed against another item, upgraded from regenerate to discard, or extended by editing its timestamp. Each of those is a test. Forged, replayed, upgraded and expired all return the same 403 — distinguishing them would tell somebody probing which of those they had achieved. There is no signable publish and asking for one finds no handler, which is asserted both on the routes and on the template's copy.

Two small differences from the issue text, both recorded in the plan: the signature covers (itemId, action, expiry) rather than (draftId, ...), because item_drafts.item_id is UNIQUE and is what every route already addresses — a second identifier only for signatures would mean two names for one row. And the recipient is an admin setting rather than an environment variable, for the same reason drafting_model is one: it is changed by whoever runs the shop, not by whoever deploys it.

One gap was in my plan rather than the code: nothing wired INTAKE_ACTION_SECRET into either stack, so this would have shipped with signed links permanently off and nothing saying why. Both compose files now interpolate it, and docs/ops/production-stack-cutover.md went from fourteen names to fifteen — checked against the file, not from memory.

Verified: 367 backend unit, 329 backend integration, 30 frontend unit, lint and builds clean on both projects with zero warnings.

Closes #224

Done, merged in PR #265, and deployed. The `intakeDraft` template is editable from the settings screen like every other one, with `reviewUrl` as its only required placeholder. The drafting worker sends it after a draft commits, fire and forget — a mail failure must never mark a draft that was written correctly as failed. **The plan made one decision this issue did not, and it changed the shape of the feature.** Mail scanners and corporate link-rewriting gateways issue a GET against every URL in a message before a human sees it. A `GET .../discard` would therefore have fired itself on delivery, carrying a valid signature and looking entirely legitimate in the log — and nobody would have known to go and recover it. So the signed link is a safe GET that confirms and a POST that acts. It costs one extra click, and it has its own test. The signature covers the item, the action and the expiry together, so a link cannot be replayed against another item, upgraded from regenerate to discard, or extended by editing its timestamp. Each of those is a test. Forged, replayed, upgraded and expired all return the same 403 — distinguishing them would tell somebody probing which of those they had achieved. There is no signable publish and asking for one finds no handler, which is asserted both on the routes and on the template's copy. Two small differences from the issue text, both recorded in the plan: the signature covers `(itemId, action, expiry)` rather than `(draftId, ...)`, because `item_drafts.item_id` is UNIQUE and is what every route already addresses — a second identifier only for signatures would mean two names for one row. And the recipient is an admin setting rather than an environment variable, for the same reason `drafting_model` is one: it is changed by whoever runs the shop, not by whoever deploys it. **One gap was in my plan rather than the code:** nothing wired `INTAKE_ACTION_SECRET` into either stack, so this would have shipped with signed links permanently off and nothing saying why. Both compose files now interpolate it, and `docs/ops/production-stack-cutover.md` went from fourteen names to fifteen — checked against the file, not from memory. Verified: 367 backend unit, 329 backend integration, 30 frontend unit, lint and builds clean on both projects with zero warnings. Closes #224
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#224