- Home
- Email testing
- Email verification testing
Test email verification flows
Sign up, receive the real confirmation email, follow the verification link, and assert the account actually flips from unverified to verified — including what happens if the link gets clicked twice.
No credit card required.
How it works
From unverified to confirmed, and back again
Including the negative case: what happens when the same link gets clicked twice.
- 1
Sign up, unverified
Create the account as usual and assert your app shows the unverified state — a banner, a locked feature, whatever it does.
- 2
Inbox receives the confirmation
Programmable Inbox receives the real confirmation email your app sends the moment the account is created.
- 3
Follow 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
Assert verified, then replay it
Assert an explicit verified state, then visit the same link again. Check the documented replay behavior of your app, whether a safe no-op or a clear rejection.
Integration example
A verification flow, confirmed and replayed
Signs up, follows the real confirmation link, then visits it a second time to check the replay behavior.
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('email-verification-testing application contract', async ({ page }) => {
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/signup')
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: 'confirm your email', 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')
}
await page.goto(link)
await expect(page.locator('[data-testid=verified-status]')).toHaveText('Verified')
// This template assumes replay is a safe no-op; adapt to your app's contract.
await page.goto(link)
await expect(page.locator('[data-testid=verified-status]')).toHaveText('Verified')
})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, expect
configuration = programmableinbox.Configuration(access_token=os.environ["PI_API_KEY"])
def test_email_verification_testing(page: Page):
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/signup")
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="confirm your email", 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")
page.goto(link)
expect(page.locator("[data-testid=verified-status]")).to_have_text("Verified")
# Assumes replay is a safe no-op; adapt to your application's contract.
page.goto(link)
expect(page.locator("[data-testid=verified-status]")).to_have_text("Verified")FAQ
Common questions
- How is this different from testing a magic link?
- A verification link confirms an address is real and flips an account flag — it usually doesn't sign you in on its own. A magic link is a passwordless sign-in mechanism. They often look similar in code, but they're testing different guarantees; see the magic-link testing page if that's what your flow actually does.
- How do I test resending the verification email?
- Record the first message ID, trigger your app's resend action, and retry until a different matching message arrives. A latest-message query alone can still return the old email while delivery is pending. Assert the new link works and test whether your app invalidates the original link.
- What if my app requires verifying within a time limit?
- 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 silently doing nothing. Your app controls token expiry; email storage is separately subject to the inbox plan's retention limits.
- Can I test signup with an already-registered email?
- Yes — reuse the same inbox address across two signup attempts in the same test and assert your app's actual behavior, whether that's a clear error or a fresh verification email to the existing account.
- 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.
- Magic-link testing
Test a passwordless sign-in URL in a fresh browser session.
- 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