Sending & Testing

How do I set up automated (E2E) testing?

Build an end-to-end test: use a sending_access API key, send to the mailbox simulator addresses, then assert on the returned email id and the webhook events.

You can verify your whole sending integration automatically — request, response, and event delivery — without mailing real people. The recipe: send to the mailbox simulator addresses with a scoped key, assert on the returned email id, then assert on the webhook events MailBlastr fires for that email.

What to set up

  1. 1
    Use a sending_access key

    Create a dedicated sending_access API key for tests (see Create API key). It can send but cannot delete domains or other keys, so a leaked CI secret is far less dangerous. Store it as a CI secret, never in the repo.

  2. 2
    Send to simulator addresses

    Send to delivered@, bounced@, and complained@mailblastr.dev to exercise the delivered, bounce, and complaint paths deterministically — from your verified domain.

  3. 3
    Assert on the response

    A successful POST /emails returns { "id": "…" } with HTTP 200. Capture that id; everything downstream keys off it.

  4. 4
    Assert on events

    Confirm the expected event arrives — either by receiving a webhook for that id, or by polling GET /emails/:id, whose response includes an events array.

Example test

Send to the delivered simulator, then poll the email until a delivered event shows up. The same pattern works for bounced@ (expect a bounced event) and complained@ (expect a complained event).

import { MailBlastr } from 'mailblastr';

const mb = new MailBlastr(process.env.MAILBLASTR_TEST_KEY); // a sending_access key, from CI secrets

async function send(to) {
  const { data, error } = await mb.emails.send({
    from: 'Acme <hello@yourdomain.com>',
    to: [to],
    subject: 'E2E delivery test',
    html: '<p>ping</p>',
  });
  if (error) throw new Error(`send failed: ${JSON.stringify(error)}`);
  return data.id; // assert: id is a non-empty string
}

async function waitForEvent(id, type, { tries = 20, delayMs = 3000 } = {}) {
  for (let i = 0; i < tries; i++) {
    const { data: email } = await mb.emails.get(id);
    if ((email?.events || []).some((e) => e.type === type)) return email;
    await new Promise((r) => setTimeout(r, delayMs));
  }
  throw new Error(`timed out waiting for ${type} on ${id}`);
}

// Delivered path
const id = await send('delivered@mailblastr.dev');
await waitForEvent(id, 'delivered');
console.log('delivered ✓');

A CI flow

  1. Store the sending_access key as an encrypted CI secret (e.g. MAILBLASTR_TEST_KEY).
  2. On each run, send one email per path: delivered@, bounced@, complained@mailblastr.dev.
  3. Assert each POST /emails returns 200 with a string id.
  4. Wait for the matching event — delivered, bounced, complained — via your webhook receiver or by polling GET /emails/:id.
  5. Fail the build if any expected event does not arrive within the timeout.
Prefer webhooks over polling when you can: stand up a test webhook endpoint, capture events keyed by email id, and let your test wait on that. Polling GET /emails/:id is the simplest fallback when a receiver is hard to expose from CI.

Browser E2E with Playwright

If you drive a real browser flow (e.g. a Next.js app with a route that sends), Playwright is a natural fit. Expose an endpoint that triggers a send to a simulator address, then exercise it from a test. There are two approaches, and you’ll typically want both:

  • Call the real API — drives the full path including MailBlastr’s response, so it catches integration regressions. It does consume your daily quota, so keep these few.
  • Mock the response — intercept the request with page.route(...) and fulfill it with a canned { "id": "…" } body. This tests *your* app’s flow without hitting the API or spending quota.
// e2e/app.spec.ts
import { test, expect } from '@playwright/test';

test('calls the real MailBlastr API', async ({ page }) => {
  await page.goto('http://localhost:3000/api/send');
  // The route returns the email id on success
  await expect(page.getByText('id')).toBeVisible();
});

A minimal playwright.config.ts mainly needs testDir (where your specs live), outputDir (where results go), a webServer block so Playwright boots your app before the run, and a projects array for the browsers to test:

playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import path from 'path';

const baseURL = 'http://localhost:3000';

export default defineConfig({
  timeout: 30 * 1000,
  testDir: path.join(__dirname, 'e2e'),
  outputDir: 'test-results/',
  retries: 2,
  webServer: {
    command: 'npm run dev',
    url: baseURL,
    timeout: 120 * 1000,
    reuseExistingServer: !process.env.CI,
  },
  use: { baseURL, trace: 'retry-with-trace' },
  projects: [
    { name: 'Desktop Chrome', use: { ...devices['Desktop Chrome'] } },
    { name: 'Desktop Firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'Mobile Safari', use: devices['iPhone 12'] },
  ],
});

Then install the browsers and run the suite:

npx playwright install
npx playwright test
Whichever approach you use, always test against a simulator address (e.g. delivered@mailblastr.dev) rather than a real inbox, so the run never harms your deliverability.
Simulator sends still count toward your daily quota. Keep E2E volume small (a few emails per run) and avoid running the suite in a tight loop.