{"record":{"id":"1b141c4cb6d230eb","repo":"TryGhost/Ghost","slug":"no-invitation-token-found-in-url-for-email","errorCode":null,"errorMessage":"No invitation token found in URL for ${email}","messagePattern":"No invitation token found in URL for (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"e2e/helpers/playwright/fixture.ts","lineNumber":214,"sourceCode":"    try {\n        messages = await emailClient.search({subject: 'has invited you to join', to: email});\n    } catch (error) {\n        const detail = error instanceof Error ? `: ${error.message}` : '';\n        throw new Error(`No staff invitation email found for ${email}${detail}`);\n    }\n\n    const message = messages[0];\n\n    if (!message) {\n        throw new Error(`No staff invitation email found for ${email}`);\n    }\n\n    const detailedMessage = await emailClient.getMessageDetailed(message);\n    const inviteUrl = new URL(extractInviteLink(detailedMessage));\n    const token = inviteUrl.pathname.split('/').filter(Boolean).at(-1);\n\n    if (!token) {\n        throw new Error(`No invitation token found in URL for ${email}`);\n    }\n\n    return token;\n}\n\nasync function createStaffAccount(page: Page, emailClient: EmailClient, role: AssignableStaffRoleName): Promise<StaffAccount> {\n    const staffAccountFactory = createStaffAccountFactory(\n        page.request,\n        email => getInvitationToken(emailClient, email)\n    );\n\n    return await staffAccountFactory.create({role});\n}\n\n/**\n * Playwright fixture that provides a unique Ghost instance for each test\n * Each instance gets its own database, runs on a unique port, and includes authentication\n *","sourceCodeStart":196,"sourceCodeEnd":232,"githubUrl":"https://github.com/TryGhost/Ghost/blob/47d8b0e2ad2fd4757d3bc45f46c3ac165ff8a1fe/e2e/helpers/playwright/fixture.ts#L196-L232","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Log inviteUrl.pathname and the extracted link to see the actual shape; update extractInviteLink() if the template changed.","Make token extraction robust: check pathname segments, fall back to a search param (e.g. token=), decode URL first.","Confirm the email found is actually the staff-invite email (not a related notification) by checking the sender/subject.","Re-run invitation flow and inspect the raw email HTML to locate the real link."],"exampleFix":"// before\nconst token = inviteUrl.pathname.split('/').filter(Boolean).at(-1);\nif (!token) throw new Error(`No invitation token found in URL for ${email}`);\n\n// after\nconst token = inviteUrl.pathname.split('/').filter(Boolean).at(-1)\n    || inviteUrl.searchParams.get('token')\n    || inviteUrl.hash.match(/invitation\\/([\\w-]+)/)?.[1];\nif (!token) throw new Error(`No invitation token found in URL for ${email}: ${inviteUrl.toString()}`);","handlingStrategy":"validation","validationCode":"function extractToken(url: URL): string | undefined {\n    return url.pathname.split('/').filter(Boolean).at(-1)\n        || url.searchParams.get('token')\n        || url.hash.match(/invitation\\/([\\w-]+)/)?.[1];\n}\nif (!extractToken(inviteUrl)) {\n    throw new Error(`No token in invite URL: ${inviteUrl.toString()}`);\n}","typeGuard":"function hasInvitationToken(url: URL): boolean {\n    return Boolean(\n        url.pathname.split('/').filter(Boolean).at(-1)\n        || url.searchParams.get('token')\n        || url.hash.match(/invitation\\/[\\w-]+/)\n    );\n}","tryCatchPattern":null,"preventionTips":["Make token extraction robust across pathname, search param, and hash shapes.","Log the full invite URL on failure to catch template drift early.","Confirm extractInviteLink() selects the staff-invite link, not a footer/unsubscribe link."],"tags":["e2e","email","invitations","url-parsing","template-drift"],"backgroundTag":null,"analyzedSha":"47d8b0e2ad2fd4757d3bc45f46c3ac165ff8a1fe","analyzedAt":"2026-08-13T01:25:26.651Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}