August 5, 2025

Scheduled email delivery with Cypress

Scheduling emails, e.g. “send later," digests, or cron jobs adds timing complexity to your tests. Messages shouldn't arrive right away, but they still need to show up within the intended window.

This post walks through how to validate those delayed or cron-style deliveries in Cypress, using Mailisk to catch and assert emails over time.

What We're Testing

Here's what we want to verify:

  • The email doesn't arrive before the scheduled time.
  • The subject and body match the expected content for that run.
  • Delivery happens within a tolerance window, which is useful for cron jobs or testing queue drift.

We'll be using Mailisk's Cypress command cy.mailiskSearchInbox() to poll for incoming messages and check both the received_timestamp and content.

Test Strategy

Here's the general shape of the test:

  1. Use a unique recipient per run (like test.${Date.now()}@<namespace>.mailisk.net) so old emails don't interfere.
  2. Trigger your app's “schedule send" endpoint with a sendAt time a little in the future.
  3. Poll Mailisk for the message using:
    • to_addr_prefix to filter by the one-off recipient.
    • subject_includes to avoid false positives.
    • from_timestamp set just before sendAt to start polling close to delivery time.
  4. Give it a slightly longer timeout to handle queue/cron lag.
  5. Assert that the email arrived after the scheduled time and that the content matches.

Example: Scheduling 30 Seconds in the Future

This pattern is for “send later" or on-demand jobs you can trigger via API.

// cypress/e2e/scheduled-email.cy.ts

describe("Scheduled email delivery", () => {
  const namespace = Cypress.env("MAILISK_NAMESPACE");
  const recipient = `cron.${Date.now()}@${namespace}.mailisk.net`;

  it("delivers after scheduled time and matches content", () => {
    // Schedule ~30s in the future
    const sendAtMs = Date.now() + 30_000;
    const scheduledTs = Math.floor(sendAtMs / 1000); // Mailisk uses seconds

    // Trigger your app's scheduling endpoint
    cy.request("POST", "/api/schedule/newsletter", {
      to: recipient,
      sendAt: new Date(sendAtMs).toISOString(),
      subject: "Weekly Newsletter",
      body: "Hello from our scheduled job!",
    })
      .its("status")
      .should((resp) => expect([200, 201, 202]).to.include(resp.status));

    // Poll Mailisk starting just before the schedule
    cy.mailiskSearchInbox(
      namespace,
      {
        to_addr_prefix: recipient,
        subject_includes: "Weekly Newsletter",
        from_timestamp: scheduledTs - 10, // small buffer before send
      },
      {
        timeout: 1000 * 90, // give it up to 90s
      }
    ).then(({ data }) => {
      expect(data.length).to.be.greaterThan(0);
      const email = data[0];

      // Assert timing + content
      expect(email.received_timestamp).to.be.at.least(scheduledTs);
      expect(email.subject.toLowerCase()).to.contain("weekly newsletter");
      expect(email.text.toLowerCase()).to.contain("scheduled job");
    });
  });
});

Here we're adding a small buffer window to from_timestamp: scheduledTs - 10, to ensure that we don't miss a message that arrived a short moment before the scheduled time. With this, the test may fail since our window is strict, but it'll be easier to debug the reason since we'll know that the email was delivered.

Validating Cron Windows

If your production job runs on a fixed cron schedule (e.g., hourly), you typically can't set sendAt. Instead, verify that the email arrives within a tolerance window around the expected cron tick.

Your options here are more limited:

  • Expose a test-only trigger (e.g., POST /api/admin/cron/run-now) to make testing fast. This way you only need to be concerned with the time it takes from trigger to sending, not the cron delay.
  • If jobs run at the top of every hour in UTC, align your test to the next boundary: calculate the time until the next :00, set from_timestamp to that moment plus a small buffer, and use a long enough timeout so mailiskSearchInbox can find the email.
  • If jobs run on an interval but the exact phase is unknown, wait for 2× the interval (plus buffer). This covers both the scheduler's phase offset and the interval itself.

The last two approaches are generally not recommended, as they can cause a big delay to your test pipeline.

Timeouts and from_timestamp

  • Increase timeouts and tolerances if your email pipeline or ESP is slow.
  • Align from_timestamp with when you expect delivery, not when the test starts.
  • If you think you missed a message, widen the window a bit earlier (e.g., scheduledTs - 30).

Example for a 10-minute tolerance:

cy.mailiskSearchInbox(namespace, { to_addr_prefix, from_timestamp }, { timeout: 1000 * 60 * 10 });

Practical Tips

  • Use ${Date.now()} in your recipients to isolate runs.
  • Keep assertions tolerant to a few seconds of clock skew.
  • Normalize case in assertions.
  • Use UTC everywhere — daylight savings shifts will ruin your day otherwise.
  • If you expect multiple emails, assert the count and sort by received_timestamp.

Wrap-Up

Testing scheduled and cron-driven emails isn't hard once you anchor your assertions to when the email should arrive, not just whether it did.
By combining from_timestamp, unique recipients, and generous timeouts, you can get Cypress tests that stay reliable even when queues or cron jobs are a bit “real-world messy."

If you're running into flaky results or delayed messages, try logging received_timestamp values in your test output — seeing the actual drift helped us spot a queue delay once that only occured under CI load.

Ready to start testing emails?
Create a free account.

Get started