- Home
- Email testing
- Magic-link testing
Test passwordless magic-link logins
Request a magic link, receive the real email, pull the sign-in URL straight out, and follow it in a fresh browser context โ the way an actual user clicking from their phone would.
No credit card required.
How it works
From requested link to a real signed-in session
Followed the way an actual user would โ in a fresh browser context, not the session that requested it.
- 1
Request the magic link
Enter the inbox's address wherever your app asks for an email to sign in with โ no password field involved.
- 2
Inbox receives it
Programmable Inbox receives the real sign-in email your app sends โ not a mock, not an intercepted request.
- 3
Extract the link
Read the plain-text message body and match the expected URL in your test. The example uses a regex; it does not consume a structured links field. Adapt parsing to your email template.
- 4
Follow it & assert
Open the link in a fresh browser context, the way a real user clicking from a phone or another device would, and assert you're signed in.
Integration example
A magic-link login, opened in a fresh context
Requests the link, extracts it, then opens it the way a user clicking from another device would.
Illustrative integration templates: these examples have not been executed against your app.Install the linked SDK and Playwright test runner (or pytest-playwright for Python), install a Playwright browser, and set PI_API_KEY in your environment. Use an approved receiving domain and a key with email_inboxes:create and email_messages:read for tests that create inboxes. Replace URLs, selectors, search terms, and assertions with your application contract. Keep credentials out of source control, use unique addresses, and delete test inboxes after runs with a separately authorized cleanup key.
import { test, expect } from '@playwright/test'
import { randomUUID } from 'node:crypto'
import { Configuration, EmailInboxesApi } from '@programmableinbox/sdk'
const api = new EmailInboxesApi(new Configuration({ accessToken: process.env.PI_API_KEY }))
test('magic-link-testing application contract', async ({ page, browser }) => {
const inbox = await api.createEmailInbox({
createEmailInboxRequest: {
email: `test-${randomUUID()}@mail.programmableinbox.com`,
name: 'CI link test',
},
})
// Supply any other required fields or seed an account for your app.
await page.goto('https://staging.yourapp.com/login')
await page.fill('#email', inbox.data.email)
await page.click('button[type=submit]')
// Retry for delivery, then parse the expected link from plain text.
let body = ''
await expect(async () => {
const result = await api.getEmailInboxMessages({
id: inbox.data.id, q: 'sign in', limit: 1,
})
body = result.data.messages[0]?.bodyText ?? ''
expect(body).not.toBe('')
}).toPass({ timeout: 20_000, intervals: [1000, 2000, 3000] })
// Adapt the regex to your template and expected path. Not a general HTML parser.
const link = body.match(/https:\/\/staging\.yourapp\.com\/[^\s<>"']+/)?.[0]
if (!link) throw new Error('Expected application link missing from email')
if (new URL(link).origin !== 'https://staging.yourapp.com') {
throw new Error('Unexpected link origin')
}
// A new page in the old context would still share its cookies.
const freshContext = await browser.newContext()
try {
const signedInPage = await freshContext.newPage()
await signedInPage.goto(link)
await expect(signedInPage.locator('[data-testid=account-menu]')).toBeVisible()
} finally {
await freshContext.close()
}
})import os
import re
import time
from uuid import uuid4
from urllib.parse import urlparse
import programmableinbox
from programmableinbox.api.email_inboxes_api import EmailInboxesApi
from playwright.sync_api import Page, Browser, expect
configuration = programmableinbox.Configuration(access_token=os.environ["PI_API_KEY"])
def test_magic_link_testing(page: Page, browser: Browser):
with programmableinbox.ApiClient(configuration) as api_client:
api = EmailInboxesApi(api_client)
inbox = api.create_email_inbox(create_email_inbox_request={
"email": f"test-{uuid4()}@mail.programmableinbox.com",
"name": "CI link test",
})
# Supply other required fields or seed an account for your app.
page.goto("https://staging.yourapp.com/login")
page.fill("#email", inbox.data.email)
page.click("button[type=submit]")
body = ""
for attempt in range(10):
result = api.get_email_inbox_messages(id=inbox.data.id, q="sign in", limit=1)
if result.data.messages:
body = result.data.messages[0].body_text or ""
if body:
break
if attempt < 9:
time.sleep(2)
assert body, "Expected email did not arrive within the retry budget"
# Adapt this plain-text regex to your template and expected path.
match = re.search(r"https://staging\.yourapp\.com/[^\s<>\"']+", body)
assert match, "Expected application link missing from email"
link = match.group()
parsed = urlparse(link)
assert (parsed.scheme, parsed.netloc) == ("https", "staging.yourapp.com")
fresh_context = browser.new_context()
try:
signed_in_page = fresh_context.new_page()
signed_in_page.goto(link)
expect(signed_in_page.locator("[data-testid=account-menu]")).to_be_visible()
finally:
fresh_context.close()FAQ
Common questions
- How is this different from testing an OTP flow?
- An OTP is a short code you type back into a form your test is already on. A magic link is a URL you follow โ often from a different device or a fresh session than the one that requested it. If your flow uses a typed code instead, see the OTP testing page for that pattern.
- Why open the link in a fresh browser context instead of the same page?
- Following the link in the same Playwright page that requested it shares cookies with that session, which can mask bugs a real user would hit โ someone clicking a magic link from their phone has no existing session at all. A new browser context tests cross-session login, but it does not emulate another device. If your app intentionally binds links to the requesting session, assert that policy instead.
- What if the link expires before I click it?
- Request the link as usual, then wait past your app's expiry window before visiting it, and assert your app rejects it with a clear error rather than a broken page. Your app controls token expiry; email storage is separately subject to the inbox plan's retention limits.
- Can I test what happens if the same link is clicked twice?
- Yes โ visit the extracted URL once to sign in, then visit it again in another fresh context and assert your app either rejects it or handles the replay safely, depending on what your flow is supposed to do.
- Does this work in CI?
- Yes. Creating the inbox and reading the message back are both plain API calls authenticated with a scoped key โ nothing interactive that would break in a headless runner.
Choose the next email workflow
- Email testing
Plan transactional email checks across content, delivery, and authentication.
- Email OTP testing
Use extracted email codes when the user types a code into a form.
- Email verification testing
Confirm signup changes the account from unverified to verified.
- AI agents
Let an MCP-connected agent read test messages with scoped permissions.
- Email automation
Route matching incoming messages to your downstream webhook workflow.
Spin up your secondary inbox
Create a programmable address, grab your first OTP, and wire up a rule in minutes. Self-host for free, or sign up for managed cloud โ either way, you own your data.
No credit card required ยท AGPL v3 license ยท Community supported