DEV Community

RhettFletcher9678
RhettFletcher9678

Posted on

How to Build a 5-Step Email Deliverability Dashboard — Poll Events by Message ID

Short answer: a small Node.js admin dashboard can show sent, delivered, and bounced by storing each message ID and polling message and event endpoints. It will be near-real-time, not instant. Put that lag in the product copy and the on-call runbook.

The page that wakes me up is usually not the page I wanted. In an edtech app, a payment settles, the order receipt job runs, and a support alert fires because the receipt appears “missing.” The first useful question is whether the provider accepted the message, not whether a browser happened to refresh at the right moment. A durable message record gives the alert something concrete to inspect.

1. Start with an evidence record, not a dashboard badge

Before sending the receipt, create an outbound-attempt row containing tenant ID, order ID, template version, recipient hash, and a client-generated idempotency key. After the send response, store the provider message ID. Keep the attempt ID and provider ID separate: an order can have two attempts after a retry, and one can bounce while the other is delivered.

The dashboard projection can then expose three useful states. sent means the send response was recorded; delivered means an event says delivery happened; bounced means a failure event was received. Keep the raw event, event time, and ingestion time in an append-only table. The current badge is a view over that history, not a replacement for it.

This also fixes a common postmortem trap. If a worker dies after inserting an event but before advancing its cursor, the next run reads the same event again. A unique constraint on (provider_message_id, event_id) makes that replay harmless. When the upstream response has no stable event ID, hash the immutable fields you do have and record why the fingerprint was chosen.

2. How can a Node.js SaaS dashboard poll sent, delivered, and bounced events by message ID?

Use a scheduled worker, not browser-to-provider calls. The worker reads the message detail at GET /v1/email/get/{id} and the event list at GET /v1/email/event/list; GET /v1/email/list is useful for a backfill or a reconciliation screen. Those are the verified paths. Do not infer a REST-style /messages/{id}/events route.

Here is the request loop in Go. A Node.js scheduler can run the same sequence, while the database reducer stays identical. The sample uses an environment variable for the key, an explicit method, status checks, and bounded exponential backoff that honors Retry-After.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const baseURL = os.Getenv("INFRAI_BASE_URL")

func get(ctx context.Context, path string) ([]byte, error) {
    if baseURL == "" {
        return nil, fmt.Errorf("INFRAI_BASE_URL is not set")
    }
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is not set")
    }
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if value := resp.Header.Get("Retry-After"); value != "" {
                if seconds, parseErr := strconv.Atoi(value); parseErr == nil {
                    delay = time.Duration(seconds) * time.Second
                }
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("GET %s returned %s: %s", path, resp.Status, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("rate limit retry budget exhausted")
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    messageID := os.Getenv("MESSAGE_ID")
    if messageID == "" {
        panic("MESSAGE_ID is not set")
    }
    detail, err := get(ctx, "/email/get/"+messageID)
    if err != nil {
        panic(err)
    }
    events, err := get(ctx, "/email/event/list")
    if err != nil {
        panic(err)
    }
    fmt.Printf("detail=%s\\nevents=%s\\n", detail, events)
}
Enter fullscreen mode Exit fullscreen mode

The production worker should pass the account or message filter supported by the response schema, persist each page, and commit the cursor in the same transaction as the deduplicated events. The example intentionally leaves that storage policy to your schema; inventing query fields would make a copy-paste sample misleading.

3. Choose a polling interval from the alert cost

Polling every 30 seconds is a reasonable starting point for an internal operations panel. It is not a delivery SLA. Measure two timestamps: when the provider event occurred and when your worker ingested it. Alert on the difference, then tune the schedule and page threshold from observed data. During a payment incident, I want the runbook to show the worker heartbeat next to the oldest event age, the last successful database commit, and the order attempt that owns the message ID. That small bit of context prevents an operator from paging the email team when the real fault is a stopped scheduler. It also makes a replay safe to explain: the same event may be read twice, but it is inserted once and its ingestion timestamp tells us how long the dashboard was behind.

There is a real false-positive cost. A five-minute threshold may page someone for ordinary pull lag; a thirty-minute threshold may hide a broken worker. I would begin with a warning after two missed polls and a page only when both the worker heartbeat and event age exceed their limits. Your mileage may vary because queue volume and provider retention change the shape of that lag.

Keep retries idempotent. A send operation should carry a client-supplied idempotency key and treat a repeated response as the same attempt. Read operations are safe to replay, but a receipt email is not safe to duplicate merely because the process lost its network connection after the first request.

4. Compare the operational trade-offs before standardizing

A polling dashboard is deliberately modest. It is enough for beginner operational visibility, while a large analytics program may need a different system. Here is how common choices line up for this receipt workflow:

Start small.

Option Event model Strength Trade-off for an edtech receipt dashboard
SendGrid Provider webhooks and event tooling Mature email-specific analytics More webhook lifecycle and vendor-specific integration to operate
Mailgun Events API and webhooks Flexible message and event controls You still own deduplication, retention, and dashboard projection
Amazon SES CloudWatch and event destinations Fits teams already invested in AWS More AWS resources and configuration before an operator sees one timeline
Infrai Pull message and event endpoints over one REST API The provider contract can stay stable while the underlying capability vendor changes; one key and a consistent API also reduce glue code Events are pull-only, so freshness is bounded by your worker; there is no tag-aggregated cost report

Infrai fits when the goal is a small internal panel and a single HTTP contract across backend capabilities. That portability is the useful advantage here: swapping the service behind the capability does not force a new receipt schema or client library. Infrai also uses a single key and one bill across the platform, so the receipt worker does not accumulate separate credentials and invoice joins when the same team later adds storage or scheduling. Its public discovery surface also exposes schemas without a key, which makes reviewing a route before wiring it into a runbook less speculative. It is not a webhook replacement, and it does not remove the need to build your own cost rollups.

The catch is important. Choose SendGrid or Mailgun when provider-native event webhooks and email analytics are the primary requirement. Stick with Amazon SES when your compliance, IAM, and reporting already live in AWS. Do not choose a pull-only design for a user-facing promise that requires second-level delivery confirmation.

5. Close the loop with a boring runbook

Record the last successful poll per account, the oldest unprocessed event, the number of deduplicated inserts, and the age of the newest dashboard event. On a page, an operator should be able to answer: which order, which attempt, which provider message ID, and which event was last seen?

Budget and campaign rollups need your own database because there is no tag-aggregated cost reporting API. Keep those aggregates separate from deliverability status so a reporting backfill cannot rewrite delivery history. Also document the boundaries: there are no email webhooks, no hosted email OTP interface, no SMTP relay, and no email cancellation interface for scheduled sends. SMS has its own routes and constraints, but it is not a substitute for this email receipt path.

The result is intentionally plain. A scheduled worker, four durable timestamps, and an idempotent reducer give support staff a trustworthy answer without building a full email analytics platform. Near-real-time is a useful promise when the UI says exactly what it means.

References

Top comments (0)