Skip to content
All posts

Building SaaS email reminders in Go: scheduling, retries and duplicate sends

7 min read

“Can we send them a reminder?”

It sounds like a small addition to a SaaS product. The application already knows something is overdue, and an email provider can deliver the message. Put a scheduled task between the two and the feature seems almost finished.

Then consider the user who completes the action just before the next run. Or the reminder that sends successfully just before the application crashes. When the process starts again, how does it know whether to send another copy?

These are the decisions hidden inside that small request. They matter whether the email asks someone to submit a timesheet, finish an application or update a reading: the system needs to remember enough to act sensibly when work is repeated or interrupted.

MileMinder, my mileage-tracking app, provides a compact example. Its reminders use a Go scheduler, stored timestamps and an email channel. Following that implementation makes it possible to see what a simple design handles, where its guarantees end, and what would need to change before adding another scheduler.

We’ll start with the point where an apparently successful send can become a duplicate, then work back through the timing rule and a test that exercises the scheduler.

One reminder, two operations

Submitting an email and recording that it was sent are separate operations. Here is their boundary in MileMinder’s scheduler:

if err := s.Channel.Send(ctx, notify.Recipient{Email: u.Email}, msg); err != nil {
    s.logf("alerts: send reminder for user %s vehicle %s: %v", u.ID, rec.ID, err)
    return
}
if err := s.ReminderState.PutReminderState(ctx, VehicleReminderState{
    UserID:         u.ID,
    VehicleID:      rec.ID,
    LastRemindedAt: now,
}); err != nil {
    s.logf("alerts: persist reminder state for user %s vehicle %s: %v", u.ID, rec.ID, err)
}scheduler.go

The excerpt is reformatted from the implementation. The behaviour is unchanged: send first, then save the timestamp.

The gap between sending and saving

Suppose the notification channel submits the message successfully, but persisting LastRemindedAt fails:

09:00  Reminder is due
09:00  Send succeeds
09:00  Saving the reminder timestamp fails
10:00  Next sweep reads the old timestamp
10:00  Reminder is still due; another send is possible

This is a possible failure sequence in the source, not a reported production incident.

Saving the timestamp first would move the problem. A crash after saving but before sending would leave the application suppressing a reminder that never went out.

Reordering two operations does not make them one atomic operation.

There is uncertainty at the network boundary too. A provider may accept a message even if the application never receives the response. An error therefore does not always mean that retrying will produce the first copy.

The current implementation leaves the timestamp unchanged when the channel returns an error. A later sweep can try again while the reminder remains due. That gives us another opportunity to send, but no exactly-once guarantee or durable history of individual attempts.

Deciding when a reminder is due

MileMinder asks users for a fresh odometer reading at an interval they choose. Reminders are opt-in and configured per vehicle. The hosted scheduler checks immediately on startup and then periodically, with an hourly sweep as the default.

An hourly check does not mean an hourly email. The scheduler compares the current time with stored dates. After loading the previous reminder state, the timing decision is:

triggerFrom := readingDate
// Previous reminder state has already been loaded and checked.
if state != nil && state.LastRemindedAt.After(triggerFrom) {
    triggerFrom = state.LastRemindedAt
}
if now.Sub(triggerFrom) < time.Duration(interval)*24*time.Hour {
    return
}scheduler.go

This is an abridged excerpt: the state lookup and its error handling sit between the first assignment and the comparison in the source.

The baseline is whichever is later: the most recent reading date or the last reminder timestamp. Each answers a different question.

A fresh reading means the user has already done the thing we were going to request. A recent reminder means we have already asked and should wait before asking again. Without the second timestamp, an overdue reading could trigger an email on every sweep.

This rule suppresses ordinary repeats across successive runs. It does not reserve the reminder for a particular worker.

The timing promise is deliberately specific: intervals are elapsed days, with weekly resolving to seven and monthly to thirty. A local-time appointment such as “Monday at 9am” would need a different model. The scheduler also handles missing settings and first-reading cases before reaching this check.

Keep the sending boundary replaceable

When a reminder is due, a renderer builds the subject, plain text and HTML. It receives the vehicle’s status and creates the message without opening a mail connection. A notification channel handles submission.

The SMTP adapter packages text and HTML as alternative versions of the same email. With SMTP unconfigured, the application uses a logging channel instead.

That boundary makes the scheduler testable. Tests can observe messages through a fake channel without sending real email. It also makes the word “success” worth treating carefully: a successful logging call has sent nothing to a recipient.

For SMTP, successful submission still does not prove inbox arrival or completion of the requested action. Providers distinguish these stages too: Resend’s event definitions separate a successful send request from delivery to the recipient’s mail server. Neither establishes that the user recorded a new reading.

Check the scheduler with a clock

Waiting a week is an inconvenient way to test a weekly reminder. The scheduler accepts a clock function, so its tests can advance time directly.

This existing test starts with a stale reading, sends once, records a fresh reading and checks again five days later:

func TestReminderNewReadingResetsAnchor(t *testing.T) {
    ctx := context.Background()
    f := newReminderFixture(t, alertDate("2025-04-20"))
    f.save(t, "golf", policyVehicle(5000)) // last reading 2025-04-11
    f.enableReminder(t, "golf", ReminderSettings{Frequency: FrequencyWeekly})

    f.sched.RunOnce(ctx)
    if got := len(f.fake.Deliveries()); got != 1 {
        t.Fatalf("first run deliveries = %d, want 1", got)
    }

    v := policyVehicle(6000)
    v.Readings["2025-04-20"] = model.Reading{Miles: 6000}
    f.save(t, "golf", v)
    f.now = alertDate("2025-04-25")
    f.sched.RunOnce(ctx)
    if got := len(f.fake.Deliveries()); got != 1 {
        t.Fatalf("deliveries after fresh reading = %d, want 1", got)
    }
}reminders_scheduler_test.go

Only explanatory comments and formatting have been shortened here. The assertion counts fake-channel submissions: after the fresh reading, there should still be just one.

Because the reading and reminder share a date, the reminder timestamp alone would keep this test quiet five days later. The test exercises the combined send-and-update path; it does not independently establish that a newer reading moves the baseline. That rule is visible in the scheduler code above. To isolate it in a test, we would need a later reading and a check after the old reminder interval but before the new reading interval.

The fake clock and channel make those scenarios testable without waiting or sending real email. This test does not exercise the browser, SMTP, competing workers or the send/save failure from the opening; those require separate checks.

Before adding a second scheduler

Now imagine two application instances running the same sweep. Both read the old timestamp. Both decide the reminder is due. Both send before either saves its result.

The elapsed-time check can be correct in each process and still produce two emails. Adding concurrency changes the coordination problem.

Before running multiple senders, I would introduce a durable record for each intended reminder occurrence, with a uniqueness constraint and an atomic claim. Workers could then compete for recorded work rather than independently discovering permission to send it. Recovery would need a way to reclaim abandoned work, and workers should recheck current preferences and whether the action is still outstanding before submission.

That would address work ownership. The external send boundary would still need attention. Retries should retain the same occurrence identity so they can reuse a provider idempotency key where supported. The next legitimate reminder needs a new identity. Provider guarantees also have limits: Resend currently documents a 24-hour idempotency window, so application recovery cannot assume unlimited deduplication.

These are proposed changes, not capabilities of the implementation shown above. They would need failure tests and workload measurements before making claims about scale.

The useful distinction is between preventing the next normal sweep from sending too soon and making repeated or concurrent attempts safe. A timestamp handles the first. Following the send/save boundary through a crash shows why the second needs more than another time comparison.




Subscribe to get future posts via email or grab the RSS feed

Built by Jack Bishop — engineering lead & maker.

© 2026 Jack Bishop