Team invitation email: invite someone to a workspace
Updated
An invitation arrives from someone the recipient may not expect to hear from, in the name of a product they may not know. Trust is the whole job of the email.
What it must make clear
- Who invited them, by name and email
- What they are being invited to (the workspace name)
- What role they will have
- When the invitation expires
- How to ignore it safely if it was a mistake
Workflow
- A member enters an email and a role.
- You store an invitation row: workspace, inviter, email, role, token hash, expiry (7 days is common).
- You send the email with an accept link.
- On accept, the user signs in or signs up with that email, you check the token, add the membership, and mark the invitation used.
- Inviters can resend (new token, old one invalid) or revoke.
Implementation (Laravel with the package)
<?php
namespace App\Services;
use App\Models\Invitation;
use Illuminate\Support\Str;
use Inboxili\Laravel\Facades\Inboxili;
class InviteMember
{
public function handle(\App\Models\User $inviter, \App\Models\Workspace $workspace, string $email, string $role): Invitation
{
$token = Str::random(48);
$invitation = Invitation::updateOrCreate(
['workspace_id' => $workspace->id, 'email' => strtolower($email)],
['role' => $role, 'invited_by' => $inviter->id, 'token_hash' => hash('sha256', $token), 'expires_at' => now()->addDays(7), 'accepted_at' => null]
);
Inboxili::emails()->send([
'to' => $invitation->email,
'from_email' => 'team@yourdomain.com',
'from_name' => $inviter->name . ' via Acme',
'subject' => '{{inviter}} invited you to {{workspace}} on Acme',
'html_body' => '<p>{{inviter}} ({{inviter_email}}) invited you to join <strong>{{workspace}}</strong> as {{role}}.</p>'
. '<p><a href="{{url}}">Accept the invitation</a>. It expires on {{expires}}.</p>'
. '<p>If you were not expecting this, you can ignore this email.</p>',
'text_body' => "{$inviter->name} invited you to {$workspace->name}: " . url("/invitations/accept?token=$token"),
'template_data' => [
'inviter' => $inviter->name,
'inviter_email' => $inviter->email,
'workspace' => $workspace->name,
'role' => $role,
'url' => url("/invitations/accept?token=$token"),
'expires' => $invitation->expires_at->toFormattedDateString(),
],
]);
return $invitation;
}
}
Dispatch the call from a queued job (SendInboxiliEmail) if you want it off the request path. Note from_name uses the inviter's name but the address stays yours, because the API only sends from verified senders.
Things that go wrong
- Accepting with a different email. Require the signed-in account's email to match, or ask the user to confirm.
- Display-name spoofing. The inviter controls their own name, so escape it, and never let it contain the
<or>characters in a header position. Use it in the body with the inviter's real email beside it. - Invitation spam. Rate-limit invitations per member and per workspace.
- Zombie invitations. Expire them and let admins see pending ones.
- Leaking membership. Do not reveal whether an address already has an account.
Delivery events
A bounced invitation should show as "failed to deliver" in the pending list so the inviter can correct the address.
Frequently asked questions
- Is an invitation transactional or marketing?
- It is transactional: one user triggered it for a specific recipient. Keep the content to who invited them and what they are joining.
- Should I send reminders?
- One reminder a few days later is reasonable. More starts to look like spam, and the person who invited them can resend manually.
Official SDKs
Send your first invitation
Create a workspace, verify a domain, and make your first API call.