TryGhost/Ghost · error · Error

No invitation token found in URL for ${email}

Error message

No invitation token found in URL for ${email}

What it means

An invitation email was found and fetched in detail, but extractInviteLink() returned a URL whose pathname split into segments has no trailing token (the last non-empty segment is empty/missing). Ghost invitation URLs look like /ghost/#/invitation/<token>; if the link is malformed, truncated, or points elsewhere, no token can be extracted.

Source

Thrown at e2e/helpers/playwright/fixture.ts:214

    try {
        messages = await emailClient.search({subject: 'has invited you to join', to: email});
    } catch (error) {
        const detail = error instanceof Error ? `: ${error.message}` : '';
        throw new Error(`No staff invitation email found for ${email}${detail}`);
    }

    const message = messages[0];

    if (!message) {
        throw new Error(`No staff invitation email found for ${email}`);
    }

    const detailedMessage = await emailClient.getMessageDetailed(message);
    const inviteUrl = new URL(extractInviteLink(detailedMessage));
    const token = inviteUrl.pathname.split('/').filter(Boolean).at(-1);

    if (!token) {
        throw new Error(`No invitation token found in URL for ${email}`);
    }

    return token;
}

async function createStaffAccount(page: Page, emailClient: EmailClient, role: AssignableStaffRoleName): Promise<StaffAccount> {
    const staffAccountFactory = createStaffAccountFactory(
        page.request,
        email => getInvitationToken(emailClient, email)
    );

    return await staffAccountFactory.create({role});
}

/**
 * Playwright fixture that provides a unique Ghost instance for each test
 * Each instance gets its own database, runs on a unique port, and includes authentication
 *

View on GitHub (pinned to 47d8b0e2ad)

Solutions

  1. Log inviteUrl.pathname and the extracted link to see the actual shape; update extractInviteLink() if the template changed.
  2. Make token extraction robust: check pathname segments, fall back to a search param (e.g. token=), decode URL first.
  3. Confirm the email found is actually the staff-invite email (not a related notification) by checking the sender/subject.
  4. Re-run invitation flow and inspect the raw email HTML to locate the real link.

Example fix

// before
const token = inviteUrl.pathname.split('/').filter(Boolean).at(-1);
if (!token) throw new Error(`No invitation token found in URL for ${email}`);

// after
const token = inviteUrl.pathname.split('/').filter(Boolean).at(-1)
    || inviteUrl.searchParams.get('token')
    || inviteUrl.hash.match(/invitation\/([\w-]+)/)?.[1];
if (!token) throw new Error(`No invitation token found in URL for ${email}: ${inviteUrl.toString()}`);
Defensive patterns

Strategy: validation

Validate before calling

function extractToken(url: URL): string | undefined {
    return url.pathname.split('/').filter(Boolean).at(-1)
        || url.searchParams.get('token')
        || url.hash.match(/invitation\/([\w-]+)/)?.[1];
}
if (!extractToken(inviteUrl)) {
    throw new Error(`No token in invite URL: ${inviteUrl.toString()}`);
}

Type guard

function hasInvitationToken(url: URL): boolean {
    return Boolean(
        url.pathname.split('/').filter(Boolean).at(-1)
        || url.searchParams.get('token')
        || url.hash.match(/invitation\/[\w-]+/)
    );
}

Prevention

When it happens

Trigger: The email body's invite link is malformed (missing the token segment). extractInviteLink() grabbed the wrong anchor (e.g. an unsubscribe link). Email template changed so the token lives in a different URL shape. The link uses a query param instead of a path segment.

Common situations: Ghost invitation template changed URL structure; email client renders text-only and the link is wrapped; extraction regex/select picks the wrong link; invitation was for a different role/flow with a different URL.

Related errors


AI-assisted analysis of TryGhost/Ghost@47d8b0e2ad (2026-08-13). Data as JSON: /api/errors/1b141c4cb6d230eb. Report an issue: GitHub.