Abandoned cart email reminders that never double-send
How I built abandoned cart email reminders for Fotobank: one hourly job, clear skip rules, a record written before each send so a crash never double-sends.
By Hamza Ghouri
An abandoned cart email is one of the few marketing emails people tend to welcome, as long as it arrives once, at a sensible time, about things they can still buy. Send it twice, or to someone who already paid, and it becomes spam with your logo on it. This is how I built cart reminders for Fotobank, a stock photo marketplace, so they go to the right people, at today's prices, and never twice, plus the queue rules I used for admin email on the same site.
Which carts get an abandoned cart email
The job runs every hour on one app instance. Running it on a single instance keeps two servers from picking up the same hour and racing each other to the same inbox.
Each run looks at two kinds of unfinished purchase:
- Signed-in carts. A visitor with an account added photos and left.
- Unpaid checkouts. They got as far as checkout and did not pay.
Both qualify when they were last touched between 24 hours and 14 days ago. Under 24 hours, people are often still deciding, and a reminder feels like someone watching over their shoulder. Past 14 days, the intent has gone cold and the email reads like a stranger's.
Each qualifying cart gets one reminder, priced at today's prices. Prices change, and a reminder quoting an old price that checkout then contradicts is worse than no reminder at all.
The skip rules that keep reminders polite
Most of the work in this feature is deciding who should not get the email. The job skips a person when any of these is true:
- They bought something since the cart was last touched.
- They were reminded in the last 30 days, or were already reminded after this cart changed.
- They unsubscribed.
- They are an internal account.
Then it cleans up the cart itself. Photos that are no longer available are dropped from the email, and so are photos the person already owns. If nothing is left after that, there is nothing to remind them about, and no email goes out.
I like writing these rules as a plain list in the spec before writing any code. Each one is a case where a well-meaning email would annoy a real customer, and each one becomes a test.
Record the reminder before you send it
This is the rule that makes the whole thing safe. The reminder is recorded in the database first, and only then is the email sent.
Think about the two ways a crash can land. If you send first and record second, a crash between the two leaves no record, so the next hourly run sends the same email again. If you record first and send second, a crash between the two leaves a record with no email, so the customer simply gets no reminder. Missing one reminder costs nothing anyone will notice. Sending two looks broken.
That ordering is what idempotent email sending comes down to for a job like this: the record is the claim, and only the run that wins the claim sends. A unique constraint makes the claim atomic, so two overlapping runs cannot both win. Stripe's engineering blog explains the same idea for payments in designing APIs with idempotency, where a retried request has its side effects happen only once. The shape I'd use, with illustrative table names:
import { Pool } from "pg";
const pool = new Pool();
async function claimReminder(userId: string, cartId: string): Promise<boolean> {
// unique (cart_id, cart_updated_at) means one reminder per version of a cart
const { rowCount } = await pool.query(
`INSERT INTO cart_reminders (user_id, cart_id, cart_updated_at, sent_at)
SELECT $1, c.id, c.updated_at, now() FROM carts c WHERE c.id = $2
ON CONFLICT DO NOTHING`,
[userId, cartId]
);
return rowCount === 1;
}
// only the caller that got true sends the email
If the send itself fails with an error you can see, you can mark the row and let a later run retry. The case you cannot see is the process dying mid-send, and record-first is what keeps that case from turning into a duplicate.
One-click unsubscribe with a signed link
Every reminder carries an unsubscribe link, and that link is signed. My default is an HMAC of the user's id with a server secret, so nobody can unsubscribe someone else by guessing ids, and the link works without logging in.
The endpoint answers two methods. A GET is the person clicking the link in the email. A POST is the one-click path, where the mail client itself unsubscribes the person from its own button. That second path is defined in RFC 8058: the message carries a List-Unsubscribe header with the HTTPS URL and a List-Unsubscribe-Post: List-Unsubscribe=One-Click header, and the client sends a POST to that URL. It is much kinder than a link that lands on a login form.
Ship it switched off, with a dry run
The reminder job shipped switched off behind an environment flag. Deploying the code and turning on the emails are two separate decisions, and I want the second one to be a config change I can reverse in a minute.
It also came with a dry-run script. A dry run should go through the same selection and skip rules and show who would get a reminder and what would be in it, without recording or sending anything. Reading that output against real data is the fastest way to spot a rule that is too loose before a customer does. When the list looks right, you flip the flag.
Admin email through a queue that resumes
The same site has admin email: someone on the team writes a message and sends it to a list of customers. That runs through a background queue with similar rules.
- Up to 200 recipients per send. Small enough that a mistake stays small.
- One message per recipient. Nobody sees anyone else's address, and each delivery succeeds or fails on its own.
- Per-recipient status saved. Each recipient's status is stored as the queue works through the list, so if the process restarts halfway, the queue resumes where it stopped and skips everyone already sent.
The sender address has no inbox, so every email carries Reply-To: support@, and replies land with a person. It is an easy header to forget, and without it customer replies to your announcements go nowhere.
If you are building a queue for this in Node.js, the same job-table approach I describe in running slow jobs in the background with queues works for email, and the idempotency thinking is the same one behind replying first and fanning out notifications later.
How long should you wait before sending an abandoned cart email?
I use a window of 24 hours to 14 days since the cart was last touched, with one reminder per cart and no more than one reminder in 30 days. A day gives people time to come back on their own, and two weeks is about as long as a cart still means anything. Whatever window you choose, the skip rules matter more than the timing.
Where I'd start
Write the skip rules first, then record each reminder before sending it, behind a unique constraint. Add a signed unsubscribe link that accepts both GET and one-click POST. Ship it off behind a flag with a dry-run script, and read the dry-run output before you turn it on. The HTML side of these emails has its own traps, which I cover in HTML email gotchas in Gmail, and Fotobank itself is on my projects page.