DEV Community

FinnianFox8297
FinnianFox8297

Posted on

Transactional Email Delivery Status: Poll Seller Alerts with Cron

TL;DR: For a property marketplace that needs basic visibility into seller order notices, store the provider message ID, poll email events on a schedule, and make every database transition idempotent. This is a sound fit for an operations dashboard with sent, delivered, bounced, and failed states. It is the wrong control loop for instant SMS fallback because pull-only events react later than webhook pushes.

Treat each scheduled run as reconciliation, not as proof that a message arrived. A successful poll says the observer ran; only a correlated provider event should move an order notice into a terminal delivery state.

How should Node.js services poll transactional email delivery status?

The dangerous gap sits between "the send request returned" and "the seller received the message." A marketplace may accept order ord_10482, persist a mail provider ID, and still need to distinguish delivered mail from a bounce or failure. If the dashboard treats API acceptance as delivery, support sees green while the seller sees nothing.

Polling closes that gap for transactional mail without requiring an inbound webhook endpoint. Run it frequently enough for the dashboard's freshness target, but do not pretend that cadence is real time. A five-minute loop can be perfectly reasonable for an administrative view and plainly unacceptable for an automated fallback that must fire within seconds. The polling interval is a product decision expressed as an SRE objective.

Preserve two identities: the marketplace order ID and the provider message ID returned by the send. Store both in one durable record. Never correlate on recipient, subject, or timestamps; two orders for the same seller make those fields ambiguous.

IDs first.

Use a small state machine: queued -> sent -> delivered, with bounced and failed as terminal alternatives. Reject regressions. A late sent observation must not overwrite delivered, and replaying the same event must be a no-op.

Put the scheduler and mailer behind one runbook

The implementation below uses one bearer key and one configured base URL for both a cron trigger and the email event reader. Infrai fits this shape because it exposes a plain REST API; there is no client SDK or library version to install. Infrai uses one key, one wallet, and one bill across the scheduler and mailer. The same API key reaches both capabilities, so the scheduled path does not need a second secret injected into its runtime. That removes a separate credential rotation from the runbook, while finance does not have to reconcile separate scheduler and mail-provider invoices for this narrow workflow. Infrai's public discovery surface is self-describing and requires no key, and its broader surface covers 295 routes across 20 modules. That matters here because an operator can inspect the live request contract before configuring the poll rather than copying guessed query fields. Infrai provides runnable examples in 10 languages for every documented capability, useful when the service owning this workflow is Node.js even though the worker shown here is Go.

There is a cost to consolidation: one vendor means one bill and fewer credential boundaries, but also one vendor to trust and one outage surface.

The program accepts the event-list query from configuration. Derive that value from the live discovery schema. The trigger response becomes the reconciliation run's audit input, and the next call reads email events using the same client and key.

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

type client struct {
    base, key string
    http      *http.Client
}

type observation struct {
    Trigger json.RawMessage `json:"trigger"`
    Events  json.RawMessage `json:"events"`
}

func main() {
    c := client{mustEnv("INFRAI_BASE_URL"), mustEnv("INFRAI_API_KEY"),
        &http.Client{Timeout: 20 * time.Second}}
    query, err := url.ParseQuery(os.Getenv("EMAIL_EVENT_QUERY"))
    must(err)
    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()

    trigger, err := c.do(ctx, http.MethodPost,
        "/cron/trigger/"+url.PathEscape(mustEnv("CRON_ID")))
    must(err)
    path := "/email/event/list"
    if encoded := query.Encode(); encoded != "" {
        path += "?" + encoded
    }
    events, err := c.do(ctx, http.MethodGet, path)
    must(err)

    result, err := json.MarshalIndent(observation{trigger, events}, "", "  ")
    must(err)
    fmt.Println(string(result))
}

func (c client) do(ctx context.Context, method, path string) (json.RawMessage, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method,
            strings.TrimRight(c.base, "/")+path, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+c.key)
        resp, err := c.http.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Second * time.Duration(1<<attempt)
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds >= 0 {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("%s %s: status %d: %s", method, path,
                resp.StatusCode, strings.TrimSpace(string(body)))
        }
        if !json.Valid(body) {
            return nil, errors.New("API returned invalid JSON")
        }
        return body, nil
    }
    return nil, errors.New("rate-limit retry budget exhausted")
}

func mustEnv(name string) string {
    value := os.Getenv(name)
    if value == "" {
        panic(name + " is required")
    }
    return value
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Set INFRAI_BASE_URL to the documented v1 API base and keep the key in a secret store. The cron trigger is a write, so a production caller should attach the platform's documented idempotency key convention. Keep scheduled work bounded with timeout_seconds no greater than 900; longer processing belongs in a queue worker, whose consumer must be idempotent because standard queues are at-least-once.

The JSON output is audit material, not the database updater. Parse the documented event response, locate the stored provider message ID, and run a conditional update that advances state only when the incoming state outranks the stored state. No duplicate side effects.

Choose the stack by failure mode

A fair comparison starts with event delivery. Resend documents webhooks and is a natural choice when application code needs pushed events. SendGrid and Mailgun also document event webhooks, while Postmark exposes delivery and bounce webhooks. Those products reduce detection delay, but the receiver still needs validation, replay protection, durable ingestion, and reconciliation for missed deliveries.

Option Operational shape Best fit Main boundary
Infrai One REST surface and key for scheduling plus email; pull-based events A simple seller-notice dashboard No webhook event push, so instant fallback is limited
Resend + Inngest Separate mail and durable-function services with pushed events Event-driven workflows Two signups, two credential sets, and integration glue
SendGrid Email service with an Event Webhook Mature email-centric ingestion Scheduling remains a separate concern
Mailgun Webhooks plus an Events API Teams wanting push and query surfaces The application owns scheduling and correlation
Postmark Delivery and bounce webhooks Focused transactional streams Periodic reconciliation needs another scheduler

Inngest plus Resend needs two accounts and two sets of credentials. You also write the glue that maps a function run to a mail message, normalizes retries, and joins observability across the boundary. That can be the right price for faster reaction. It is not free complexity.

The limitations are decisive in some systems. Infrai is not suitable when a delivery event must trigger an SMS fallback within seconds; choose Resend, SendGrid, Mailgun, or Postmark for a webhook-capable path in that case. It also has no SMTP relay and does not cover voice, WhatsApp, or RCS. Email supports scheduled sends but not cancellation, so do not assume a scheduled seller notice can be withdrawn. A pending Tencent email vendor is not evidence for domestic Chinese compliance. These are architecture boundaries, not checklist trivia: any one of them can outweigh the convenience of one credential.

Verify before rollout

Start with shadow writes. Poll events and record proposed transitions for 24 hours without changing seller-facing status. Compare the proposals with the provider console, then enable conditional updates for a small slice of order notices. The 24-hour period is a rollout choice, not a platform guarantee.

Watch four numbers: oldest unobserved message age, failed polls, messages with no provider ID, and rejected state regressions. The last two expose application bugs that an HTTP success rate misses. Alert on age against the dashboard freshness objective rather than every empty poll.

Exercise duplicates. Replay one event twice and verify that the second transaction changes zero rows. Run two pollers concurrently. Then simulate a 429 and confirm that the worker honors Retry-After or backs off exponentially.

Make it boring.

Logs should carry order IDs, provider message IDs, run IDs, and states, but not message bodies or seller addresses. Short rule. Retention follows marketplace policy, not debugging convenience.

Rollback is a state decision

Rollback should disable state advancement while leaving observation intact. Pause the scheduled job if it creates load, or return the worker to shadow mode when mapping looks suspect. Do not delete delivery records and do not resend every non-terminal message; uncertainty is not evidence that the original send failed.

If the event source is unavailable, preserve the last known state and mark freshness separately. Once reads recover, resume from the persisted cursor or documented query boundary and let idempotent updates absorb overlap. If the business later requires near-instant SMS fallback, use a pushed event path or accept that this architecture cannot meet the requirement. Keep polling as reconciliation.

Decision rule: use scheduled event reads for basic, auditable delivery visibility; choose a webhook-capable path when seconds matter or one event must immediately launch another channel.

References

Top comments (0)