Skip to content

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.

Koltrix Team5 min read
Syntax-highlighted code on a dark computer screen
Photo by Chris Ried on Unsplash
On this page(11 sections)
  1. When building it yourself makes sense
  2. The architecture in one picture
  3. The tables
  4. Defining an email as a query
  5. The scheduled job
  6. The sender
  7. Suppression and opt-outs
  8. Templates in version control
  9. Testing before it reaches real users
  10. Mistakes that bite in month three
  11. Key takeaways

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:

  1. An events table. Your app records what users do: signed up, connected an integration, invited a teammate.
  2. A sends table. One row per lifecycle email actually sent to a user, which makes every email idempotent.
  3. A scheduled job. Runs every few minutes, finds users who qualify for each email, and enqueues sends.
  4. 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-Key header 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.

SharePost on XLinkedIn
More in Guides →