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

  1. A member enters an email and a role.
  2. You store an invitation row: workspace, inviter, email, role, token hash, expiry (7 days is common).
  3. You send the email with an accept link.
  4. On accept, the user signs in or signs up with that email, you check the token, add the membership, and mark the invitation used.
  5. 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.

Related