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.
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
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.
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.mdScope
A new
intakeDraftkey inemailTemplates.ts, so the copy is editable from the settings screen like every other template, withreviewUrlin 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_SECRETover(draftId, action, expiry), verified with a timing-safe comparison, valid for 30 days.state='queued'for the worker to pick up.state='discarded'and the item unpublished, recoverable from the queue. A one-click destructive action reachable from an inbox should not be final./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
readydraft is visible and actionable in the admin whether or not its notification ever sent.Part of #220
Done, merged in PR #265, and deployed.
The
intakeDrafttemplate is editable from the settings screen like every other one, withreviewUrlas 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 .../discardwould 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, ...), becauseitem_drafts.item_idis 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 reasondrafting_modelis 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_SECRETinto either stack, so this would have shipped with signed links permanently off and nothing saying why. Both compose files now interpolate it, anddocs/ops/production-stack-cutover.mdwent 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