Application email QA
Test password-reset email in Laravel: assertions, failures, cleanup
Assert Laravel password-reset mail generation locally, then verify SMTP handoff and inbound UX with a receive-only mailbox—without treating consumer inboxes as CI.

The short path
Test Laravel password-reset email in three layers: (1) application generation with Mail fakes / notifications, (2) SMTP handoff to a real or captured transport, (3) inbound user experience in a receive-only mailbox you control. Do not collapse all three into a flaky end-to-end hit against production ESP quotas.
Mailby Quick Inbox is appropriate for manual receiving checks of apps you own—not as a silent claim that a CI API is required for every assert. The developer console and API are live for programmatic inbox workflows when you need them; this guide still shows a manual receive path first so local PHPUnit stays fast.
Laravel context and boundaries
Laravel ships password resets via notification mail (Illuminate\Auth\Notifications\ResetPassword or custom). Typical stack:
Password::sendResetLink()- Notification →
MailMessage - Mailer config (
MAIL_MAILER, SMTP, log, array) - Signed URL with token + expiry
Boundaries:
- Only test systems you are authorized to test.
- Prefer
Mail::fake()for unit/feature tests; use real SMTP only in staging. - Mailby is receive-only—it will not send the reset for you.
- Consumer Quick Inbox retention is short; clean up tokens and test users. See data retention.
Official docs: Laravel password reset (rel="nofollow noopener") and mail testing (rel="nofollow noopener").
Demonstration: assertions that catch real bugs
Test date: 2026-09-24. Stack: Laravel feature test + manual inbound check.
Layer A — generation (PHPUnit)
Mail::fake();
$this->post('/forgot-password', ['email' => $user->email])
->assertSessionHasNoErrors();
Mail::assertSent(ResetPassword::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
Assert subject, action URL host, and expiry relative to config('auth.passwords.users.expire') when you customize templates.
Layer B — SMTP handoff (staging)
Point MAIL_MAILER=smtp at a staging relay. Trigger one reset for a dedicated QA user. Confirm the ESP accepted the message (logs / provider dashboard).
Layer C — inbound UX (manual receive-only)
- Create a disposable address in Quick Inbox for the QA user email field (or use your domain’s catch-all).
- Trigger reset once.
- Open the message in safe preview; copy the link; confirm it loads HTTPS on your staging host.
- Complete reset; assert login works.
- Delete the inbox / expire retention; invalidate unused tokens.
Working path: Mail fake passes in CI; one staging message received inbound; reset completes.
Failure / limitation: a team asserted only Mail::fake() while production templates pointed action URLs at http://localhost. CI stayed green; users could not reset. Inbound UX testing would have caught it—generation tests alone would not.
Mechanism and failure cases
| Layer | What breaks | Symptom |
|---|---|---|
| App | Wrong user email / throttle | No notification queued |
| Template | Bad URL / missing token | 404 or invalid signature |
| Config | Bad SMTP creds | Exception in logs; user sees generic success (depending on code) |
| ESP | Reputation / block | Accepted locally, never arrives |
| Inbound | Disposable domain blocked by your app validation | Form rejects address |
| Retention | Link opened after purge | User cannot find mail; token may still be valid server-side |
Separate token validity (server) from message availability (mailbox). Laravel can still accept a token after the temporary inbox deleted the email—if the user saved the link. Design tests for both.
Table and worked example
| Test case | Expected message | Observable evidence | Negative case |
|---|---|---|---|
| Happy path reset | Reset mail to user | Mail::assertSent + inbound link works | — |
| Unknown email | No user enumeration leak | Generic status; no mail or silent | Detailed “user missing” error |
| Throttled requests | Limited sends | 429 / status message | Unlimited mail flood |
| Expired token | Reject reset form | Validation error | Accepts expired token |
| Wrong host in link | Staging host only | URL host assert | localhost in staging mail |
| Cleanup | Token invalidated after use | Second use fails | Reusable token |
Worked example — Kai’s SaaS
Kai adds a feature test with Mail::fake() for every PR. Nightly, a staging job sends one reset to a QA address monitored in Quick Inbox. Once per release, Kai clicks the link on a phone to confirm mobile rendering. After a template refactor broke the signed URL, nightly inbound failed while unit tests still passed—exactly the split this guide recommends.
Alternatives
- Mailpit / Mailhog on local Docker for catch-all SMTP without public ingress.
- Mailby developer API for automated waiting on inbound messages when you outgrow manual clicks—/developers.
- Privacy Pro only if you need longer manual retention windows—/pricing.
- Never use customer production mailboxes for reset drills.
Related: how it works, features.
Short answers
What causes Laravel reset-mail bugs?
Config/template/host mistakes that unit fakes never see, plus ESP delivery issues.
What should I do first?
Mail::fake() asserts in CI; one staging inbound check before release.
When is a permanent address safer?
Human QA accounts that must receive resets for months—use durable QA mailboxes.
What evidence changes the recommendation?
You need parallel inbound automation → adopt the live developer API rather than scraping a browser inbox.
Sources, limitations
- Laravel official password and mail-fake docs (nofollow).
- Editorial pattern 2026-09-24; Laravel major versions differ slightly in notification class names—check your version.
- Mailby does not replace your SMTP provider.
Cleanup obligations people skip
Password-reset tests leave residue: users, tokens, rate-limit counters, ESP suppressions, and temporary inboxes. Build teardown into the test—not a wiki reminder.
Minimum cleanup:
- Invalidate or delete the QA user (or reset it to a known state).
- Ensure used tokens cannot be reused.
- Delete catcher/Mailby inboxes created for the run.
- Clear mail fakes between tests (
Mail::fake()per test method is safer than one shared fake). - Rotate SMTP credentials if a staging secret leaked into CI logs.
Leaving reset links alive in a public temporary inbox past retention is usually self-solving on Mailby Free clocks—but do not rely on that for staging links that still point at privileged environments. Prefer staging hosts with auth gates.
Customizing notifications without breaking asserts
Teams override ResetPassword to brand the template. When you do:
- Keep a feature test that renders the notification and asserts the action URL host.
- Snapshot the plain-text part if you care about multipart clients.
- Avoid embedding environment-specific hosts in service providers without config.
Example assert flavor (adapt to your Laravel version):
$notification = new ResetPassword('fake-token');
$mail = $notification->toMail($user);
$this->assertStringContainsString(config('app.url'), $mail->actionUrl ?? '');
Exact properties differ by version—read your framework source rather than copying blindly.
When temporary inbound is the wrong receiver
Do not point password-reset drills for shared production users at disposable inboxes. Do not harvest customer addresses. Do not disable signature validation “to make Selenium easier” in staging that mirrors production auth. Owned catchers and the developer API exist so you can automate without weakening crypto.
How this differs from a test-email-workflows hub
Hubs survey QA strategies. This article is a Laravel password-reset teardown: fake → SMTP → inbound UX, with a localhost-link failure case and an assertions table.
Staging data that makes resets realistic
Use a dedicated qa-reset@ identity in staging with a known password. Seed it in migrations or a db:seed --class=QaUsers. Avoid random factories that create unique emails you never watch. Attach the inbound catcher or Mailby developer inbox ID to that user in a secrets file outside git.
When testing localization, assert subjects for each locale you ship. Reset mail is often forgotten in translation QA—users in secondary languages receive blank or English-only templates that still “pass” Mail::assertSent if you only check class names.
Rate limiting and parallel CI
Laravel’s password broker throttles requests. Parallel CI jobs sharing one user will flap. Give each job a unique user or disable throttle in the testing environment explicitly. Document that prod throttles remain enabled.
Observability hooks
Log message-ids from your ESP when staging sends real mail. Correlate with inbound receive timestamps. That dataset tells you whether failures are generation, SMTP, or IMAP/API polling bugs.
Security regression tests worth adding
- Reset token single-use.
- Token expires per config.
- Cannot reset for other users by swapping emails in forms.
- Success responses do not reveal whether an email exists (enumeration).
These are application security tests adjacent to mail QA. Temporary inboxes do not cover them—feature tests do.
End-to-end staging checklist you can paste into a runbook
-
Deploy staging with
APP_URLset to the public staging host (never localhost). -
Confirm
MAIL_*points at the staging ESP or relay; send a seedMail::rawsmoke test to the QA catcher. -
Run PHPUnit with
Mail::fake()suite—must be green. -
Manually trigger forgot-password for
qa-reset@…; wait on inbound; open link on desktop. -
Repeat link open on mobile Safari/Chrome; confirm layout and HTTPS padlock.
-
Complete reset; login; attempt reuse of the same link—must fail.
-
Trigger reset again; leave unused past expiry; confirm rejection.
-
Delete inbound messages / Mailby inbox; rotate QA password back to seed if needed.
-
Attach message-id and timestamps to the release checklist.
-
Only then promote the template change to production.
Teams that skip steps 4–7 ship the classic localhost CTA. Teams that skip cleanup accumulate orphaned tokens and rate-limit debt. Temporary inboxes help step 4’s receiving side; they do not replace steps 1–3 or 6–7. When you automate step 4, move from manual Quick Inbox to the live developer API with explicit timeouts—mirroring the Selenium wait discipline in related Mailby engineering notes.
Keep Laravel version upgrade notes in the same runbook: notification namespaces and ResetPassword signatures change across majors. Re-read upstream docs on each upgrade rather than trusting old snippets.
Multipart and mobile rendering checks for reset templates
Reset emails are often plain MailMessage HTML. Add a smoke assert that the action URL appears in the text line as well as the button. Open once on a narrow viewport. Confirm the token is not logged in laravel.log at info level. These checks sit beside inbound receiving tests and catch classes of bugs temporary mailboxes alone will not reveal. Store screenshots in CI artifacts with durable retention—not in a disposable inbox.
Relating this to Selenium OTP waits
If UI tests request password resets rather than signup OTPs, apply the same unique-inbox and poll patterns described in Mailby’s Selenium timing guidance. Reset links last longer than OTPs sometimes, which can hide slow delivery until expiry edges. Still measure. Cross-link your runbooks so mail QA is one discipline—generation asserts in Laravel, timing waits in Selenium, inbound capture in catcher or /developers—not three conflicting folklore traditions.
Conclusion
Split generation, handoff, and inbound UX. Keep CI fast with fakes; prove real links with a receive-only mailbox you control. Use Quick Inbox for manual checks and /developers when you automate waiting—without pretending consumer sessions are a full substitute for owned staging infrastructure.
Try it on Mailby
Open a receive-only disposable inbox when a short-lived address fits the job — session-bound, with timed purge.
