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:
- 1A cronjob that fires on a schedule.
- 2A query that selects who should receive the email *right now* and hasn't already.
- 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)) = 8Scheduling on PandaStack
- 1Create a Cronjob in the dashboard.
- 2Set the command:
node send-digest.js. - 3Set the schedule:
0 8 * * *for 8 a.m. daily (or0 * * * *hourly for the timezone-aware version). - 4Add env vars:
EMAIL_API_KEY, and attach the database forDATABASE_URL. - 5Save and watch the execution logs.
Reliability checklist
- Idempotent: the
UNIQUE (user_id, kind, period_key)guard makes re-runs safe. - Batched:
LIMITper 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