Invitations
Invite people by email, accept by token, revoke and expire invitations.
On this page
Invitations bring people into a workspace who may not have an account yet. An admin invites an email address with a role; Navree mails a link carrying a single-use token; the invitee signs in (or signs up) and accepts, and joins the workspace with that role.
The flow
- An admin calls
POST /api/workspaces/{workspace}/invitationswith{"email", "role"}. - Navree stores the invitation with a SHA-256 digest of a random 48-character token, and once the request commits, mails the token to the address.
- The invitee opens the link in your front end, signs in or registers, and the front end posts the token to
POST /api/invitations/accept. - Navree checks the token, the expiry and the address, adds the member and marks the invitation accepted.
Sending an invitation
Over HTTP, with the workspace.invitations.create permission (owners and admins have it):
POST /api/workspaces/acme/invitations HTTP/1.1
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json
{"email": "[email protected]", "role": "admin"}{
"__type": "WorkspaceInvitation",
"id": "0199f3c3-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"workspace_id": "0199f3c2-8f4e-7c1a-9d2b-5a6e7f8091ab",
"email": "[email protected]",
"role": "admin",
"invited_by": "0199f3c2-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"expires_at": "2026-11-02T09:00:00Z",
"created_at": "2026-10-26T09:00:00Z"
}role defaults to member. The response never contains the token: it travels in the mail only.
In code, Invitations::invite returns the invitation and the plain token, for apps that deliver it another way (its Debug output hides the token):
use navree::workspace::{Invitations, WorkspaceRole};
let sent = invitations
.invite(&uow, workspace_id, "[email protected]", WorkspaceRole::Admin, Some(inviter_id))
.await?;
// sent.invitation: the row; sent.token: the token to deliverThe rules:
- The address is trimmed and lower-cased, and must look like an email address (a 400 on
emailotherwise). - The role is any built-in or configured role except
owner(a 400 onrole). To make someone the owner, invite them asadmin, then transfer ownership. - An address that belongs to a member already is a 409,
The user is already a member of this workspace. - Inviting an address again replaces its pending invitation: the old token stops working.
The mail
With MailModule in the app, the mail goes out once the request commits, through Mailer::queue (on a queue worker when the app has a queue). Without a mailer the invitation is created, nothing is mailed, and a warning is logged.
Set the page of your front end that accepts invitations, and the mail links to it with the token as a query parameter:
[workspace.invitations]
url = "https://app.example.com/invitations/accept"Subject: You're invited to join Acme
Hello,
Ada Lovelace invited you to join Acme as admin.
Accept the invitation: https://app.example.com/invitations/accept?token=Xq3...
The invitation expires in 7 days.
If you did not expect this invitation, you can ignore this email.Without url, the mail carries the token itself, for API and mobile clients. The inviter's name comes from the user directory.
To write your own mail, point mail_template at a view pair ({template}.html and {template}.txt):
[workspace.invitations]
url = "https://app.example.com/invitations/accept"
mail_template = "mail/workspace-invitation"{{ inviter }} invited you to {{ workspace }} as {{ role }}.
Join here: {{ url }}
This link works for {{ expires_in_days }} days.The template receives workspace (the name), email, role, inviter (empty when unknown), token, url (empty without url) and expires_in_days. The subject stays You're invited to join <workspace>.
Accepting
The invitee must be signed in. Your front end posts the token from the link:
POST /api/invitations/accept HTTP/1.1
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json
{"token": "Xq3..."}{
"workspace": { "__type": "Workspace", "slug": "acme", "role": "admin", "...": "..." },
"member": { "__type": "WorkspaceMember", "role": "admin", "...": "..." }
}| Situation | Status | message |
|---|---|---|
| Unknown, revoked or already used token | 404 | Invitation not found. |
| The invitation has expired | 410 | The invitation has expired. |
| Signed in as another address | 403 | This invitation was sent to a different email address. |
| Already a member | 409 | The user is already a member of this workspace. |
| More than 10 attempts a minute | 429 | Too Many Requests |
Someone without an account signs up first (POST /api/auth/register with the invited address), then accepts with the same token. Keep the token in the front end across the sign-up.
In code:
let accepted = invitations.accept(&uow, &token, user_id).await?;
// accepted.workspace, accepted.memberMatching the address
With workspace.invitations.require_matching_email (the default), only the account whose email is the invited address can accept. Navree asks the user directory for the accepting user's address. When the directory doesn't know the user (no users table, or users_table = ""), the check can't pass and acceptance is refused with the 403 above: register a user directory for your user store, or turn the check off to treat the token alone as proof:
[workspace.invitations]
require_matching_email = falseRevoking
DELETE /api/workspaces/acme/invitations/0199f3c3-0a1b-7c2d-8e3f-4a5b6c7d8e9f HTTP/1.1Revoking (permission workspace.invitations.delete) deletes the pending invitation, and its token stops working. GET /api/workspaces/{workspace}/invitations (permission workspace.invitations.read) lists the pending ones, newest first. In code: invitations.revoke(&uow, workspace_id, invitation_id) and invitations.pending(workspace_id).
Expiry
An invitation can be accepted for workspace.invitations.ttl_secs seconds, seven days by default. Expired invitations drop out of the pending list and find_by_token, and accepting one is a 410. The rows stay until you prune them:
navree workspace:prune-invitationsSchedule it daily:
#[module(register = Self::wire)]
pub struct HousekeepingModule;
impl HousekeepingModule {
fn wire(&self, m: &mut ModuleBuilder) {
m.schedule(|s| {
s.command("workspace:prune-invitations", &[])
.daily_at("03:00")
.without_overlapping();
});
}
}Showing an invitation before accepting
invitations.find_by_token(&token) returns the pending invitation (with its workspace_id, email and role), or None for an unknown, used or expired token. Use it in your own route to show "Ada invited you to Acme" before the user accepts.
Events
| Event | Name | Payload |
|---|---|---|
InvitationSent | workspace.invitation_sent | workspace_id, invitation_id, email, role, invited_by, expires_at (never the token) |
InvitationAccepted | workspace.invitation_accepted | workspace_id, invitation_id, user_id, role |
Accepting also emits MemberAdded.
Testing
Read the token from the mail the array transport kept, and move time with a fixed clock:
let sent = app.get::<Mailer>().await.array_transport().unwrap().sent();
let text = sent.last().unwrap().text.clone().unwrap();
let token = text.split("token=").nth(1).unwrap().split_whitespace().next().unwrap();
clock.advance(std::time::Duration::from_secs(8 * 24 * 3600));
app.acting_as(principal(grace))
.post("/api/invitations/accept")
.json(&json!({ "token": token }))
.send()
.await
.assert_error(410, "The invitation has expired.");Register the clock with override_value::<dyn Clock>(clock.clone()) where clock is an Arc<FixedClock>, and set mail.transport = "array".