Email testing guides

Hands-on email testing guides for developers and QA teams

← All testing guides
QA Testing

Testing Invites, Roles and Approvals: One Inbox per Test User

July 12, 2026·11 min read
A tester at a laptop with four browser tabs labeled Owner, Admin, Member and Viewer, each with its own invite and role check.

Say your team is building a project tracker. A workspace has an owner, a couple of admins, members who do the work, and viewers (clients or auditors) who should only look. Each role sends and receives different emails: invites, "you're now an admin", "Jordan asked for edit access". Testing all of that with your own work address means pretending to be four people with one mailbox, and that's exactly where bugs slip through.

The fix is simple: give every test user a separate inbox. Below we cover the manual setup with real inboxes, a role matrix, the approval cases that break most often, and a Playwright script that drives the whole cast against a local mail catcher.

Why each test user needs their own inbox

Plus-addressing ([email protected]) looks like a shortcut, but every message lands in the same mailbox. When the admin's invite and the viewer's invite both show up in one place, you can't tell whether the app sent each one to the right person or sent both to everyone. Some apps also strip the plus part, so two "different" users quietly collapse into one account.

Separate inboxes make the question easy. If the viewer's inbox shows an invite that says "as an Admin", you found a bug. If the inbox of someone who was never invited shows anything at all, you found a worse one.

  • One tab per inbox. Each new tab or private window on temp-email.ai gets its own random @snapmailnow.com address. Mail appears live, so you can watch five inboxes side by side without refreshing.
  • A clean start every run. A new address has never been seen by your app, so "brand-new user" really means brand new.
  • Long enough for a test cycle. Each inbox lasts 7 days, which covers a sprint's regression pass if you keep the tab or its link.
  • Test accounts only. Anyone with an inbox link can read it, so keep these addresses on staging and test accounts, never on a production admin.

One detail trips people up: tabs share cookies. The inboxes are separate, but your app will think every tab is the same logged-in person. For the app side, use separate browser profiles, Firefox containers or private windows from different browsers, one per user.

Cast your test users

For most team products, five users cover the interesting cases. The fifth one, the outsider, is the one people forget, and it's the one that catches leaks.

text
Tab 1  Owner     creates the workspace, pays, can delete it
Tab 2  Admin     manages people and settings, can't delete the workspace
Tab 3  Member    creates and edits work
Tab 4  Viewer    reads and comments only (a client or an auditor)
Tab 5  Outsider  never invited: should receive nothing, see nothing
Tip: Pin the tabs in this order and paste each address into your test notes next to its role. Random addresses all look alike after the third coffee.

Invites: what to check

Invites look trivial until you list the ways they go wrong. Run these with the owner sending and each other tab receiving.

  • The invite reaches only the invited address. Every other tab stays quiet.
  • The email names the right workspace, inviter and role: "Dana invited you to Acme Design as a Viewer".
  • The accept link works once. A second click shows a friendly "already accepted" page, not an error or a second membership.
  • A revoked invite stops working, even though the email is still sitting in the inbox.
  • An expired invite explains itself and offers a way to ask for a new one.
  • "Resend" invalidates the old link, or at least clicking both links doesn't create two memberships.
  • Inviting an address that already has an account adds the workspace to that account instead of starting a second sign-up.
  • Letter case doesn't create a second person: inviting Viewer@... and signing up as viewer@... is one user.

The nastiest invite bug we see is the wrong-session case. The admin is logged in and opens the viewer's invite link in the same browser, maybe because someone forwarded it. A careless app attaches the invite to whoever is logged in, so the admin quietly gains a second membership and the viewer never gets in. The right behavior is to refuse or ask "This invite is for viewer@..., switch accounts?". With labeled tabs and profiles you can reproduce it in ten seconds.

The role matrix

Write the matrix down before you test. It turns "check permissions" into small yes/no questions anyone on the team can run, and it doubles as documentation.

text
Action                      Owner   Admin   Member  Viewer
Invite people               yes     yes     no      no
Change someone's role       yes     yes*    no      no
Remove a member             yes     yes     no      no
Create and edit projects    yes     yes     yes     no
Comment                     yes     yes     yes     yes
See billing and invoices    yes     no      no      no
Export all data             yes     yes     no      no
Delete the workspace        yes     no      no      no

* an admin can't make anyone Owner or demote the Owner

Check every "no" twice: once in the UI (the button is hidden or disabled) and once by calling the action directly, for example by replaying the request from your browser's DevTools while logged in as the viewer. A hidden button is cosmetic; the server has to say no. There's a reason OWASP ranks broken access control as the top web application risk.

Emails the matrix triggers

Role changes send mail too, and each message has exactly one right audience:

  • Promoted or demoted: the affected user gets a short note with the new role. Nobody else does.
  • Removed from the workspace: the removed user is told, and their old invite and share links stop working.
  • Ownership transfer: both the old and the new owner get an email, and nothing changes until the new owner accepts.
  • Billing mail (receipts, failed payments) goes to the owner or billing contact only. An invoice in the viewer's tab is a data leak.

Approvals: request, approve, deny

Approval flows add a twist: the email asks someone to act, and the action happens through a link. Say the viewer clicks "Request edit access". The owner and the admin should each get "Jordan asked for edit access to Q4 Roadmap" with Approve and Deny buttons. Here's what breaks:

  • Only approvers get the request. The member's and the outsider's inboxes stay empty.
  • The Approve link requires the approver to be logged in as themselves. A link that approves on click, with no session, lets anyone who sees the email approve it.
  • Two approvers act within seconds of each other. The requester gets one result email, not two, and the audit log shows who acted first.
  • One admin approves and another denies. The second person sees "already approved by Sam" instead of silently flipping the result.
  • An approver is removed from the workspace before clicking. Their old link no longer works.
  • The requester gets a clear result either way. A denial with no email leaves people refreshing the page for days.
Tip: Many company mail filters open every link in an email to scan it. If your Approve link changes state on a plain GET request, a scanner can approve requests nobody read. Have the link open a page with a confirm button instead.

Worked example: a manual invite pass in 15 minutes

Run this pass on staging, where your app sends through its real email provider. It's the only version of the test that proves invites actually arrive in a real mailbox on a real domain.

  1. Open five tabs on temp-email.ai and label them Owner, Admin, Member, Viewer and Outsider. Open your app in five separate browser profiles, one per user.
  2. Sign up the owner with tab 1's address and confirm the verification email. Our email verification testing guide lists what to check in that message.
  3. As the owner, invite tabs 2, 3 and 4 with their roles. Each invite should appear in the right tab within a minute, and tab 5 should stay empty.
  4. Accept each invite from its own profile. The welcome screen should show the right workspace and role.
  5. Walk the matrix as each user and mark every cell pass or fail.
  6. As the viewer, request edit access. Approve it from the admin's profile, then send a second request and deny it from the owner's. Read both result emails in tab 4.
  7. Finally, open the viewer's old invite link in the admin's profile. The app should refuse or ask to switch accounts.

Worked example: automating the cast with Playwright

Once the manual pass is stable, automate the parts that break most: invites reaching the right person, roles that stick, and the wrong-session case. Playwright suits this well because each browser.newContext() gets its own cookies and storage (browser contexts in the Playwright docs), so one test can drive several users at once.

For automated runs, swap the real inboxes for a local mail catcher. Mailpit is a small, free SMTP server that accepts every email your app sends and shows it in a web UI and a REST API, without delivering anything anywhere. Start it with docker run -d -p 8025:8025 -p 1025:1025 axllent/mailpit and point your app's test config at SMTP host localhost, port 1025.

Mailpit accepts any address, so each test user gets a brand-new one on example.test. The .test domain is reserved for testing by RFC 2606, so a misconfigured environment can never email a real person. The helpers read mail through Mailpit's API: GET /api/v1/search finds messages with its search filters, and GET /api/v1/message/{ID} returns the full text and HTML.

TypeScript
// tests/helpers/mailpit.ts
import { expect, type APIRequestContext } from '@playwright/test';

const MAILPIT = process.env.MAILPIT_URL ?? 'http://localhost:8025';

interface Address { Name: string; Address: string }
interface Summary { ID: string; Subject: string; To: Address[] }
export interface Message extends Summary { From: Address; Text: string; HTML: string }

// A brand-new address per test user, so runs never read each other's mail.
export function uniqueAddress(role: string): string {
  return `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.test`;
}

// Mail sent to exactly this address. Mailpit's to: filter also matches
// partial addresses, so we compare the full address ourselves.
export async function inbox(request: APIRequestContext, address: string): Promise<Summary[]> {
  const res = await request.get(`${MAILPIT}/api/v1/search`, { params: { query: `to:"${address}"` } });
  expect(res.ok(), `Mailpit search returned HTTP ${res.status()}`).toBeTruthy();
  const { messages } = (await res.json()) as { messages: Summary[] };
  return messages.filter((m) => m.To.some((t) => t.Address.toLowerCase() === address.toLowerCase()));
}

export async function waitForEmail(
  request: APIRequestContext, address: string, subject: RegExp, timeout = 30_000,
): Promise<Message> {
  let id = '';
  await expect
    .poll(async () => (id = (await inbox(request, address)).find((m) => subject.test(m.Subject))?.ID ?? ''), {
      message: `No email matching ${subject} reached ${address}`,
      timeout,
    })
    .not.toBe('');
  return (await request.get(`${MAILPIT}/api/v1/message/${id}`)).json();
}

export function linkFrom(email: Message, pathPart: string): string {
  for (const m of email.HTML.matchAll(/href="([^"]+)"/g)) {
    const href = m[1].replace(/&amp;/g, '&');
    if (href.includes(pathPart)) return href;
  }
  throw new Error(`No link containing "${pathPart}" in "${email.Subject}"`);
}

Next, two small helpers for your app. The selectors and URLs are placeholders; swap in your own.

TypeScript
// tests/helpers/app.ts
import { expect, type APIRequestContext, type Page } from '@playwright/test';
import { waitForEmail, linkFrom } from './mailpit';

export const APP = process.env.APP_URL ?? 'http://localhost:3000';

export async function signUpAndVerify(page: Page, request: APIRequestContext, address: string) {
  await page.goto(`${APP}/signup`);
  await page.getByLabel('Email').fill(address);
  await page.getByLabel('Password').fill('Owner-Test-123!');
  await page.getByRole('button', { name: 'Create account' }).click();
  const mail = await waitForEmail(request, address, /confirm your email/i);
  await page.goto(linkFrom(mail, '/verify'));
  await expect(page.getByText('Email confirmed')).toBeVisible();
}

export async function acceptInvite(page: Page, inviteUrl: string, password: string) {
  await page.goto(inviteUrl);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Join workspace' }).click();
}

Now the test. It makes four unique addresses, signs up the owner, sends two invites, checks that each person got the right one and the outsider got nothing, and confirms the app refuses the wrong-session case.

TypeScript
// tests/workspace-invites.spec.ts
import { test, expect } from '@playwright/test';
import { uniqueAddress, inbox, waitForEmail, linkFrom } from './helpers/mailpit';
import { APP, signUpAndVerify, acceptInvite } from './helpers/app';

test('invites reach the right person with the right role', async ({ browser, request }) => {
  const [ownerAddr, adminAddr, viewerAddr, outsiderAddr] =
    ['owner', 'admin', 'viewer', 'outsider'].map(uniqueAddress);

  // One isolated context per user: separate cookies, like separate people.
  const owner = await (await browser.newContext()).newPage();
  await signUpAndVerify(owner, request, ownerAddr);

  await owner.goto(`${APP}/settings/members`);
  for (const [addr, role] of [[adminAddr, 'Admin'], [viewerAddr, 'Viewer']]) {
    await owner.getByLabel('Email address').fill(addr);
    await owner.getByLabel('Role').selectOption(role);
    await owner.getByRole('button', { name: 'Send invite' }).click();
  }

  const adminInvite = await waitForEmail(request, adminAddr, /invited you/i);
  const viewerInvite = await waitForEmail(request, viewerAddr, /invited you/i);
  expect(adminInvite.Text).toContain('as an Admin');
  expect(viewerInvite.Text).toContain('as a Viewer');
  expect(await inbox(request, outsiderAddr)).toHaveLength(0); // never invited

  const admin = await (await browser.newContext()).newPage();
  await acceptInvite(admin, linkFrom(adminInvite, '/invite/'), 'Admin-Test-123!');

  // Wrong-session case: the logged-in admin opens the viewer's link.
  await admin.goto(linkFrom(viewerInvite, '/invite/'));
  await expect(admin.getByText(/this invite is for/i)).toBeVisible();

  const viewer = await (await browser.newContext()).newPage();
  await acceptInvite(viewer, linkFrom(viewerInvite, '/invite/'), 'Viewer-Test-123!');
  await expect(viewer.getByRole('button', { name: 'New project' })).toHaveCount(0);
});

Mailpit takes mail in milliseconds, so the whole test runs in a few seconds and fits on every pull request. Unique addresses also mean parallel workers never read each other's mail, and there's nothing to clean up between runs. Mailpit keeps the newest 500 messages by default and drops older ones on its own.

What Mailpit can't tell you is whether the invites survive the trip through your real email provider, DNS and spam filters. That's the job of the manual pass with real inboxes, run on staging before a release. Together the two cover both questions: "does the app send the right email to the right person?" and "does it actually arrive?". For more patterns, see our Playwright email testing guide.

Common multi-user bugs we see

  • The invite goes to the inviter instead of the invitee (two swapped variables in the mailer).
  • The role in the email doesn't match the role granted: "as an Admin" in the email, Member in the app.
  • Accepting an invite while logged in as someone else attaches it to the wrong account.
  • Viewers can call write endpoints directly even though the buttons are hidden.
  • Billing and security emails go to every admin instead of the owner.
  • Approve links work without a login, or a link scanner can trigger them.

Before you close the tabs

Most of the work in multi-user testing is deciding who your users are and writing the matrix down. After that, a row of labeled tabs and a few browser profiles give you a realistic team in minutes, and a Playwright test with Mailpit keeps checking it on every build.

When you're ready for the manual pass, our disposable inbox for testing page has an address waiting. And if your product charges money, testing sign-up through payment is a natural next step. Happy testing!