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:
- Use a unique recipient per run (like
test.${Date.now()}@<namespace>.mailisk.net) so old emails don't interfere. - Capture the test-start timestamp, then trigger your app's “schedule send" endpoint with a
sendAttime a little in the future. - Search Mailisk for the message using:
to_addr_prefixto filter by the one-off recipient.subject_includesto avoid false positives.from_timestampcaptured before triggering the job, so messages from an earlier run are excluded without hiding an early delivery.
- Give it a slightly longer timeout to handle queue/cron lag.
- 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
const fromTimestamp = Math.floor(Date.now() / 1000);
// 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("be.oneOf", [200, 201, 202]);
// Start waiting now and ignore messages older than this test run.
cy.mailiskSearchInbox(
namespace,
{
to_addr_prefix: recipient,
subject_includes: "Weekly Newsletter",
from_timestamp: fromTimestamp,
},
{
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");
});
});
});
cy.mailiskSearchInbox() starts waiting as soon as the command runs. from_timestamp is only a lower-bound filter: it does not delay polling. Capturing it before the scheduling request lets the test retrieve a premature delivery and fail the received_timestamp assertion clearly.
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, set
from_timestampto a point before that boundary such asexpectedTs - buffer, and use a long enough timeout formailiskSearchInboxto 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.
- Set
from_timestampto the earliest delivery the test should inspect, usually immediately before triggering the job. - If you think you missed a message, move the lower bound earlier.
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 a test-start 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.