DEV Community

QuentinBarrett5281
QuentinBarrett5281

Posted on

How to Poll Transactional Email Delivery Status in Node.js: Cron Without Webhooks

Short answer: poll transactional email delivery status from a Node.js cron worker through the events API, but keep the password-reset template and token policy in your application. Polling is evidence collection, not authorization, and the reset token must expire independently of delivery state.

I distrust dashboards because they rarely tell me what page fired. A bounded poll is easier to reason about at 3am.

What signal are you actually trying to recover?

At 3am, a green dashboard is not a signal. The question is what page fired and which learner action can still be affected. Record an immutable send attempt, an internal attempt ID, the provider message ID, the template revision, and a digest of the rendered body. Provider labels such as queued, delivered, deferred, or bounced describe transport; they do not prove that a password changed.

A 15-minute token is a security policy, not a polling interval. Poll once a minute for ten minutes, then stop observing. A late delivery receipt remains audit data and never revives the link.

How should a cron worker reconcile events without webhooks?

Use a bounded overlap window. Each run reads attempts whose last check is old enough and whose observation deadline has not passed. Normalize provider events into your own state machine, write by event ID, and leave an attempt due when the read fails. That last rule matters: recording a failed read as a successful check creates a silent gap.

package reconcile

import (
    "context"
    "time"
)

type Attempt struct { ID, ProviderID string; LastCheckedAt, ObserveUntil time.Time }
type Event struct { ID, Kind string; ObservedAt time.Time }
type Reader interface { Events(context.Context, string, time.Time) ([]Event, error) }
type Store interface {
    Due(context.Context, time.Time) ([]Attempt, error)
    Record(context.Context, string, Event) error
    MarkChecked(context.Context, string, time.Time) error
}

func Reconcile(ctx context.Context, now time.Time, r Reader, s Store) error {
    attempts, err := s.Due(ctx, now); if err != nil { return err }
    for _, a := range attempts {
        events, err := r.Events(ctx, a.ProviderID, a.LastCheckedAt)
        if err != nil { continue }
        for _, e := range events { if err := s.Record(ctx, a.ID, e); err != nil { return err } }
        if err := s.MarkChecked(ctx, a.ID, now); err != nil { return err }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The scheduler can run every minute, with a database lease preventing overlapping workers. This is a Node.js scheduling concern even if the reconciliation core is a small Go binary; choosing one runtime over the other is a maintenance trade-off, not a delivery guarantee:

*/1 * * * * /srv/mail-reconciler --deadline=10m
Enter fullscreen mode Exit fullscreen mode

Template ownership is the operational boundary.

The application team owns subject, copy, localization, and link construction. Security owns expiry and single-use checks. The transport adapter owns credentials, provider IDs, and pagination. Render once at send time and persist the revision and digest; a later copy edit must not rewrite history. This boundary also makes rollback an ordinary code deployment instead of a dashboard ritual.

Keep personal data out of event logs where possible. An attempt ID and provider ID are enough to correlate a result. Commercial streams still need honest headers and compliance handling; the FTC's CAN-SPAM guidance is a useful baseline even when a reset message is transactional. A no-webhook poll is unsuitable when you need sub-minute reaction or provider-side event volume is too high; use a push path then, accepting its own retry and signature-verification work.

Verification and rollback come before paging anyone.

Test queued-to-delivered, deferred-to-bounced, duplicate events, and no event until the deadline. Assert that a late delivery cannot extend token validity. Instrument attempts due, read errors, normalized event kinds, expired windows, and reconciliation lag. Alert on sustained read failures or attempts nearing their deadline, not on a single missing receipt.

Run in shadow mode first: read events and write audit records without changing user state. To roll back, disable the scheduler and retain immutable attempts. The reset endpoint must check token expiry, use, and account binding itself; delivery status can inform support, but it cannot authorize a password change.

References

Top comments (0)