Skip to main content

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.