POST /subjects/:id/actions-email
Requires manage-users. The body names the required actions the link takes the
subject through — {"actions": […]}, at least one of update-password,
configure-totp, configure-passkey and generate-recovery-codes, the
vocabulary PUT /subjects/:id/required-actions takes, anything else refused
with 400, as is any other field, client_id and redirect_uri included. The
link is minted and mailed to the subject's own address by enqueueActionsLink
(@odudu/account), and the answer is 202 with no body. The two routes above
are the same core with a fixed link: one sendAccountEmail does the lookups,
the target ceiling, the three 409s, the mail and the audit row for all three,
so they cannot drift.
What the link does. Opening it shows the actions, and consumes nothing,
so a mail scanner's GET does not spend it. Submitting it sets a new
password where update-password is named, under the reset link's rules — the
tenant's policy, the previous password refused, every outstanding
password-setting link retired — and owes every other action, so the subject's next sign-in parks on
each, as it does for one an administrator set. The link alone never enrols
a factor: possession of a mailbox stands in for a forgotten password, as a
reset link's does, but enrolling a TOTP secret or a passkey still takes a
sign-in with the password and whatever factor the subject already holds. The
link lives as long as a reset link (reset_password_ttl_seconds), since it
signs its subject in by their mailbox the same way, and it is spent once.
The link offers no way back to a client. Its last page says to sign in
to the application, because a registered callback reached without a code
or state reads to a client as a failed or forged response. The body is
strict, so client_id and redirect_uri, which an earlier version took and
nothing read, are refused with 400 naming the field, as the other strict
bodies refuse an unknown one.
The 409s are the reset's: about:blank#no-email, about:blank#no-mail-relay,
and about:blank#reset-password-off when update-password is named while
the tenant's reset_password_allowed is off — its reset page would refuse
the link, and an outstanding link stops working the moment the setting is
turned off. It is held to the target ceiling and throttled per origin with
the sign-in, registration and reset submissions, as the two routes above
are. The link is never in the response, and the audit row,
subject.actions_email_send, names the actions and nothing else.
In a tenant required-actions-demo made for it, with a relay at the stack's own
postgres container, port 25, where nothing listens, and grace made through
POST /subjects with an address and no password;
P=http://localhost:3086/admin/tenants/required-actions-demo. grace's id and
the request ids are this run's own. The mail and link pages below are from an
earlier run, whose link has the same page. A link asking for a password while
the tenant's reset is off, the setting turned on, the removed client_id and
redirect_uri, an action the server does not take, then the send that is
accepted, and its audit row:
G=01a10c6a-8a75-7017-a32b-2c65e85ba94d
curl -sS -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"actions":["configure-totp","update-password"]}' "$P/subjects/$G/actions-email"; echo
curl -sS -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"reset_password_allowed":true}' "$P/settings" | grep -o '"reset_password_allowed":[a-z]*'
curl -sS -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"actions":["configure-totp","update-password"],"client_id":"actions-app","redirect_uri":"https://app.example/callback"}' \
"$P/subjects/$G/actions-email"; echo
curl -sS -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"actions":["verify-email"]}' "$P/subjects/$G/actions-email"; echo
curl -sS -D - -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"actions":["configure-totp","update-password"]}' "$P/subjects/$G/actions-email"
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
"$P/audit?resource_type=subject&resource_id=$G&action=subject.actions_email_send" \
| jq -c '.items[] | {action, outcome, detail}'
{"type":"about:blank#reset-password-off","title":"Conflict","status":409,"detail":"reset_password_allowed is off, so the tenant would refuse the link; turn it on with PATCH /settings first","instance":"01a10c6a-8a87-735b-bd5b-36e6e4d3e173"}
"reset_password_allowed":true
{"type":"about:blank","title":"Error","status":400,"detail":"body must NOT have additional properties: client_id","errors":[{"path":"client_id","message":"must NOT have additional properties"}],"instance":"01a10c6a-8abe-7e09-b8ee-d5beec34e497"}
{"type":"about:blank","title":"Error","status":400,"detail":"body/actions/0 must be equal to one of the allowed values","errors":[{"path":"actions[0]","message":"must be equal to one of the allowed values"}],"instance":"01a10c6a-8aca-7d23-a17b-ac4d9b0d97af"}
HTTP/1.1 202 Accepted
x-request-id: 01a10c6a-8ad5-71bb-9990-bb6eaee31768
cache-control: no-store
content-length: 0
Date: Mon, 05 Oct 2026 14:14:31 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"action":"subject.actions_email_send","outcome":"allowed","detail":{"actions":["update-password","configure-totp"]}}
The one mail the tenant's outbox holds, read there, since the relay delivers nothing — the actions in the order the link will ask for them, which is the one the audit row lists too:
docker compose exec -T postgres psql -U odudu -d odudu -At -c \
"select subject, body_text from email_outbox where tenant_id = (select id from tenants where name = 'required-actions-demo');"
Update your required-actions-demo account|Your administrator asks you to update your required-actions-demo account:
- Choose a new password
- Set up an authenticator app
Do so by visiting this link:
http://localhost:3000/tenants/required-actions-demo/login-actions/action-token?key=4zSeBj8AeoVl6hBnud6z6BvOaY40VCaHkMzrcbde2bI
If you were not expecting this, you can ignore this message.
--
You received this because an administrator at required-actions-demo asked you to update your account.
© 2026 Odudu · Powered by odudu
The mail text is the mail package's catalogue, with the tenant name inside
Unicode isolates (U+2068 and U+2069, invisible here), and after the -- line
the reason it was sent and the footer, whose year is that of the moment it was
queued. The block above is from a second run (tenant required-actions-demo,
grace, the same request), so its link is that run's, on localhost:3000
because the server there was told that was its public base URL, and names
nothing from the run above. Its relay was a Mailpit container, so the message
was delivered as well as queued, and Mailpit shows what arrived: the mark is an
inline image, which the SMTP sender attaches because the markup names it
cid:odudu-mark (ID is the message's id in Mailpit's list):
curl -s "http://localhost:18925/api/v1/message/$ID" | jq -c '{To: [.To[].Address], From: .From.Address, Subject, Inline: [.Inline[] | {FileName, ContentType, ContentID, Size}]}'
{"To":["grace@example.test"],"From":"odudu@actions.test","Subject":"Update your required-actions-demo account","Inline":[{"FileName":"odudu.png","ContentType":"image/png","ContentID":"odudu-mark","Size":893}]}
The same mail rendered through renderRequiredActions with these inputs gives
exactly the bytes of the block above.
What following it does is under Following a required-actions link.