Sending Lifecycle Emails From Your Own Job Queue
For founders who code: build onboarding and trial emails with an events table, a scheduled job and an email API, with idempotency and suppression built in.

On this page(11 sections)
If you can write a cron job, you can run your own lifecycle email. Onboarding nudges, trial reminders and "you haven't finished setup" emails are, underneath, just queries against your own database followed by an API call. Building them in-house gives you exact triggers, version control for your copy, and no second copy of your customer data in a marketing tool.
This guide walks through a small, reliable design. It's language-agnostic, with examples in SQL and JavaScript.
When building it yourself makes sense
Building lifecycle email in your own code is a good fit when:
- Engineers are the people who'll change the emails, at least for now.
- Your triggers depend on product data that lives in your database anyway.
- You have a handful of lifecycle emails, not dozens of segmented campaigns.
- You'd rather not sync user data to another vendor.
It's a poor fit when a non-technical marketer needs to edit copy and timing daily, or when you need a visual journey builder with branching and A/B tests. In that case, a dedicated lifecycle platform will save you time. Our comparison of a dedicated lifecycle tool versus in-house code covers that decision in more depth.
The architecture in one picture
There are four pieces:
- An events table. Your app records what users do: signed up, connected an integration, invited a teammate.
- A sends table. One row per lifecycle email actually sent to a user, which makes every email idempotent.
- A scheduled job. Runs every few minutes, finds users who qualify for each email, and enqueues sends.
- A sender. Calls your email provider's API and records the result.
Everything else, including templates, suppression and timing, hangs off these four.
The tables
create table user_events (
id bigserial primary key,
user_id bigint not null references users(id),
name text not null, -- 'signed_up', 'project_created', ...
occurred_at timestamptz not null default now()
);
create index on user_events (name, occurred_at);
create index on user_events (user_id, name);
create table lifecycle_sends (
user_id bigint not null references users(id),
email_key text not null, -- 'welcome', 'setup_nudge_day2', ...
sent_at timestamptz,
status text not null default 'pending',
primary key (user_id, email_key)
);
The primary key on lifecycle_sends is the most important line in this design. Each user can receive each lifecycle email at most once, enforced by the database. Retries, overlapping job runs and deploys mid-run can't double-send.
Defining an email as a query
Each lifecycle email is a rule: who qualifies, and when. Express it as a query that returns user IDs. Here's a "you created an account but no project" nudge, sent two days after signup:
select u.id
from users u
join user_events s on s.user_id = u.id and s.name = 'signed_up'
where s.occurred_at < now() - interval '2 days'
and s.occurred_at > now() - interval '7 days' -- don't nudge ancient signups
and not exists (select 1 from user_events e
where e.user_id = u.id and e.name = 'project_created')
and not exists (select 1 from lifecycle_sends l
where l.user_id = u.id and l.email_key = 'setup_nudge_day2')
and u.email_suppressed = false
and u.lifecycle_opt_out = false;
Notice the upper bound on the window. Without it, the first time you deploy this email, every user who ever signed up without creating a project gets it at once.
The scheduled job
The job loops over email definitions, runs each query, and inserts a pending send. Insert first, send second:
for (const email of LIFECYCLE_EMAILS) {
const userIds = await db.query(email.audienceSql);
for (const userId of userIds) {
// Claim the send. If another run already claimed it, this inserts nothing.
const claimed = await db.query(
`insert into lifecycle_sends (user_id, email_key)
values ($1, $2) on conflict do nothing returning user_id`,
[userId, email.key]
);
if (claimed.length) await queue.enqueue("send_lifecycle", { userId, key: email.key });
}
}
Run it every five or ten minutes. Lifecycle email rarely needs second-level precision, and a short interval keeps the queries cheap.
The sender
The worker renders the template and calls your provider. With the Koltrix API, a send is a single authenticated POST:
async function sendLifecycle({ userId, key }) {
const user = await getUser(userId);
if (user.email_suppressed || user.lifecycle_opt_out) return markSkipped(userId, key);
const { subject, html, text } = render(key, user);
const res = await fetch("https://api.koltrix.com/api/v2/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `lifecycle-${key}-${userId}`,
},
body: JSON.stringify({
from: "Sam at Example <[email protected]>",
to: [user.email],
subject,
body_html: html,
body_text: text,
}),
});
if (!res.ok) throw new Error(`send failed: ${res.status}`); // let the queue retry
await markSent(userId, key);
}
Two details worth copying regardless of provider:
- The idempotency key is derived from the email and user, not random. If the worker crashes after the provider accepted the message but before you recorded it, the retry sends the same key and your provider returns the original result instead of a second email. Koltrix honors the
Idempotency-Keyheader on its send endpoint for this reason. - Suppression is checked again at send time. A user can unsubscribe or bounce between the job enqueueing a send and the worker processing it.
Note that Koltrix requires the from address to be one you've created in your workspace, not just any address on a verified domain. The sending email docs cover the full request.
Suppression and opt-outs
Keep two separate flags:
email_suppressed: set when your provider reports a hard bounce or a spam complaint. Never send anything optional to these addresses. Listen to your provider's delivery webhooks to set it automatically.lifecycle_opt_out: set when a user unsubscribes from non-essential email. They still get password resets and receipts.
Every lifecycle email should include a way to opt out of that kind of mail, and the link should flip lifecycle_opt_out directly, without asking the user to log in.
Templates in version control
Store templates as files next to your code, rendered with whatever template engine your stack already uses. You get code review for copy changes, history for every edit, and the ability to test rendering in CI. Always render both an HTML and a plain-text version.
Testing before it reaches real users
- Run each audience query against production data in read-only mode and check the count before enabling the email. A surprisingly large number usually means a missing time window.
- Enable new emails for internal accounts first.
- Log every send decision, including skips and their reasons, so you can answer "why did this user get this email?"
Mistakes that bite in month three
A few problems only show up once the system has been running for a while:
- Timezone-blind scheduling. "Two days after signup" computed in UTC sends some users their nudge at 3 a.m. local time. If you store a user's timezone, hold sends until a reasonable local hour.
- Emails that outlive their reason. A "connect your calendar" nudge should stop the moment the user connects one. Re-check the audience condition in the worker, not just in the job, because state can change between the two.
- No global frequency cap. Each email looks reasonable on its own, but a new user can qualify for four of them on the same day. Add a simple rule, such as no more than one lifecycle email per user per day, and let the job skip anything that would break it.
- Copy changes without history. Because templates live in version control, you can answer "what did this email say in June?" Keep it that way by never editing copy directly on a server.
Key takeaways
- Four parts are enough: events, sends, a scheduled job and a sender.
- A unique key on (user, email) makes every lifecycle email send at most once, whatever goes wrong.
- Always bound the time window in audience queries, or your first deploy emails your entire history.
- Use deterministic idempotency keys and recheck suppression at send time.
- Build it yourself while engineers own the emails. Move to a dedicated tool when non-engineers need to.
Start with Koltrix
Your domain, one inbox, and an API that sends.
A team inbox where AI sorts and drafts (nothing is sent without your click), plus the transactional API and SMTP relay your product sends with. 7 days free, no card.


