Back to Blog
Tutorial9 min read2026-07-05

How to Send Scheduled Emails with Cronjobs

Daily digests, weekly reports, trial-expiry reminders — all driven by a cronjob. Here's how to build a reliable scheduled email job that's idempotent and won't double-send.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

The shape of a scheduled email job

Almost every product sends emails on a schedule: daily digests, weekly summaries, trial-ending reminders, abandoned-cart nudges. The naive version ("a cron that emails everyone") works in a demo and breaks in production — duplicate sends, partial failures, and timezone confusion.

This tutorial builds a scheduled email cronjob that's idempotent (safe to re-run), batched (won't hit rate limits), and observable.

Architecture

Three pieces:

  1. 1A cronjob that fires on a schedule.
  2. 2A query that selects who should receive the email *right now* and hasn't already.
  3. 3An email provider (transactional API) that does the actual sending.

The critical idea: the cron *selects work and marks it done*, so a re-run never double-sends.

The idempotency table

Don't trust the schedule alone — a job can run twice (retry, overlap, manual trigger). Track what you've sent:

CREATE TABLE sent_emails (
  id           bigserial PRIMARY KEY,
  user_id      bigint NOT NULL,
  kind         text NOT NULL,        -- 'daily_digest', 'trial_reminder'
  period_key   text NOT NULL,        -- '2026-05-07' or '2026-W19'
  sent_at      timestamptz DEFAULT now(),
  UNIQUE (user_id, kind, period_key) -- the guard against double-send
);

The UNIQUE constraint is the whole trick: inserting before sending makes a duplicate send impossible.

The job

// send-digest.js
import { pool } from "./db.js";
import { sendEmail } from "./mailer.js";

const KIND = "daily_digest";
const periodKey = new Date().toISOString().slice(0, 10); // YYYY-MM-DD

async function run() {
  // users who want the digest and haven't received today's
  const { rows: users } = await pool.query(`
    SELECT u.id, u.email, u.name
    FROM users u
    WHERE u.digest_enabled = true
      AND NOT EXISTS (
        SELECT 1 FROM sent_emails s
        WHERE s.user_id = u.id AND s.kind = $1 AND s.period_key = $2
      )
    LIMIT 500
  `, [KIND, periodKey]);

  for (const user of users) {
    try {
      // claim first — unique constraint prevents a parallel run from also sending
      await pool.query(
        `INSERT INTO sent_emails (user_id, kind, period_key) VALUES ($1,$2,$3)`,
        [user.id, KIND, periodKey]
      );
    } catch (e) {
      if (e.code === "23505") continue; // already claimed by another run
      throw e;
    }

    await sendEmail({
      to: user.email,
      subject: "Your daily digest",
      html: await buildDigest(user),
    });
  }

  console.log(`digest run complete: ${users.length} processed`);
}

run().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });

Claiming the row *before* sending means even two overlapping runs can't both send to the same user. The LIMIT 500 keeps each run bounded; the next run picks up the rest.

The mailer

Use a transactional email API (Mailgun, Postmark, SES, Resend). Don't send raw SMTP from a cron — you'll hit deliverability and rate issues.

// mailer.js
export async function sendEmail({ to, subject, html }) {
  const res = await fetch("https://api.mailprovider.com/v1/send", {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.EMAIL_API_KEY}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({ from: "hello@yourapp.com", to, subject, html }),
  });
  if (!res.ok) throw new Error(`email failed: ${res.status} ${await res.text()}`);
}

Timezones: the silent bug

"Send the daily digest at 8 a.m." — whose 8 a.m.? If you have global users, a single UTC cron sends at 8 a.m. UTC for everyone. Two approaches:

  • Simple: run hourly, and in the query select users whose local time is currently 8 a.m. (store each user's timezone).
  • Pragmatic: pick one send time in a chosen timezone and accept it. Fine for many products.
-- hourly cron; pick users for whom it's 08:00 local right now
WHERE extract(hour from (now() at time zone u.timezone)) = 8

Scheduling on PandaStack

  1. 1Create a Cronjob in the dashboard.
  2. 2Set the command: node send-digest.js.
  3. 3Set the schedule: 0 8 * * * for 8 a.m. daily (or 0 * * * * hourly for the timezone-aware version).
  4. 4Add env vars: EMAIL_API_KEY, and attach the database for DATABASE_URL.
  5. 5Save and watch the execution logs.

Reliability checklist

  • Idempotent: the UNIQUE (user_id, kind, period_key) guard makes re-runs safe.
  • Batched: LIMIT per run, plus respect your provider's rate limit.
  • Observable: log counts; alert on job failure and on suspiciously low/high sends.
  • Failure-isolated: one bad recipient shouldn't abort the whole batch (catch per-user, continue).
  • Unsubscribe respected: filter on the preference in the query, always.
  • Don't overlap: keep runs short enough to finish before the next fires, or guard against concurrency.

References

  • crontab syntax (crontab.guru): https://crontab.guru/
  • Mailgun API: https://documentation.mailgun.com/docs/mailgun/api-reference/intro/
  • Postmark transactional email: https://postmarkapp.com/developer
  • Idempotency keys (Stripe's explainer): https://docs.stripe.com/api/idempotent_requests
  • PostgreSQL unique constraints: https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS

---

Want scheduled emails with a real cron schedule, logs, and your database wired in? PandaStack cronjobs handle the schedule and inject DATABASE_URL. Build one free at https://dashboard.pandastack.io

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also