---
id: w4/m12/t002
title: Invite delivery — pending-invite row, courier email, identity link on first login
worker: worker4
status: done
estimate: 30m
depends_on: [w4/m12/t001, w4/m7/t005]
---

## Objective

Make `InviteMember` reach a human who has no account yet: a pending-invite row (email, role, expiring token) plus an invitation email sent through the m7 courier path; when the recipient signs up (or logs in) with that email, the pending invite converts into real membership (row + tuple) and the token is consumed.

## Context

- m7 ships the mail mechanism (SendGrid relay prod / Mailpit local) — reuse it; do not add a second SMTP path.
- The invite email carries a link to the dashboard signup/login; conversion happens server-side on identity resolution (match by verified email), not by trusting the link alone — the token gates the *role grant*, the email match gates the *account*.
- Expiry (e.g. 7 days) and single-use are hard requirements; re-invite replaces the pending row.
- Kratos owns identity — no shadow user records; the pending invite is bex's own mapping row.

## Steps

1. `pending_invites` migration + store queries (create/consume/expire).
2. Send path: courier template for the invitation mail with the tokenized link.
3. Conversion hook where sessions resolve (first authenticated request or a redeem endpoint): validate token + email, mint membership via t001's verb, delete the pending row.

## Files

- `lego/backend/internal/store/migrations/`
- `lego/backend/internal/` (invite service + conversion hook)
- mail template location per m7's layout

## Acceptance criteria

- [ ] Invite → mail lands in Mailpit with a working link; signup with the invited email yields membership with the assigned role.
- [ ] Expired or reused token refused; wrong-email signup gets no membership.

## Out of scope

- Dashboard UI (t004); inviting existing members (dup-invite typed error from t001).
