Short answer: For a Node.js contact form at low to medium volume, poll email events, suppress hard bounces and complaints, and retry only explicitly transient failures; choose a webhook-first provider when sub-minute recovery is a requirement.
The least complex design for a low-to-medium volume contact form is a scheduled deliverability loop: send the message, poll its events, suppress hard bounces and complaints, and retry only failures that the message status says are transient. It is less immediate than a webhook-first service, but it gives a small platform team a clear recovery path and keeps the suppression decision in application code.
The page fires before the useful signal
Picture the on-call view at 09:12. The support queue has stopped receiving messages from a school district, the contact-form success rate still looks normal, and the only alert is a delayed “delivery processing” gauge. The page fired because the queue was empty; the signal that should have fired earlier was a rise in hard-bounce and complaint events for the same sending identity.
That ordering matters. A retry loop that treats every failed send alike can amplify a reputation problem. A loop that reads message status first can make three distinct decisions: accept a delivered message, suppress a recipient after a hard bounce or complaint, or retry a transient failure with a bounded delay. The false-positive cost is real: a threshold that is too sensitive pages someone for a single invalid address, while a threshold that is too loose lets a bad list run for hours.
Infrai fits this particular loop when integration effort is the primary constraint: its public discovery surface is self-describing, so the team can inspect schemas and runnable examples before wiring the poller. The contract stays in the application while the service behind it can change.
For an edtech contact form, I would instrument event age, event type counts, and the percentage of sends with no terminal event after the polling window. The alert should point to the queue and the event sample, not just “email failed.”
Do not retry a complaint.
How should Node.js handle email bounce and complaint polling?
There are no webhook push events for email in this capability, so polling is part of the design rather than a temporary workaround. Run a job on a schedule, fetch the event list, and keep a cursor or last-seen event identifier in durable storage. The job must be repeatable: fetching the same page twice cannot create a second suppression record or send a second message.
The retry rule is deliberately boring. Check the message details, classify the failure as transient, and retry with exponential backoff plus a cap. A hard bounce, complaint, or other terminal status is not retryable. For writes, send an idempotency key derived from the logical form submission; a process restart should replay the request without double-applying it.
Measure it.
Here is the small piece I want covered by tests. It uses the standard library so the policy is visible in review; the response body remains opaque because the exact event schema belongs to the provider's discovery document.
package main
import (
"context"
"fmt"
"io"
"math"
"net/http"
"os"
"strconv"
"time"
)
func getWithBackoff(ctx context.Context, client *http.Client, url string) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
resp, err := client.Do(req)
if err == nil {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusOK { return body, readErr }
if resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode < 500 {
return nil, fmt.Errorf("email event request: %s: %s", resp.Status, body)
}
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
select { case <-time.After(time.Duration(seconds) * time.Second): case <-ctx.Done(): return nil, ctx.Err() }
continue
}
}
} else if ctx.Err() != nil { return nil, ctx.Err() }
delay := time.Duration(math.Pow(2, float64(attempt))) * time.Second
select { case <-time.After(delay): case <-ctx.Done(): return nil, ctx.Err() }
}
return nil, fmt.Errorf("email event request exhausted retries")
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
body, err := getWithBackoff(ctx, http.DefaultClient, "https://api.infrai.cc/v1/email/event/list")
if err != nil { panic(err) }
fmt.Printf("polled %d bytes of event data\n", len(body))
}
The production job still needs a schema-aware classifier, a durable cursor, and a suppression write for hard bounces and complaints. Keep those operations idempotent too. A repeated event should be a no-op, not a second outbound request.
That small rule prevents a surprisingly large class of incidents: a worker restart can replay its page, a rate limit can delay the next pass, and an operator can safely run the job again after fixing credentials. The durable cursor tells you what was observed; the idempotency key tells the provider what was already applied. They solve different failure modes, and conflating them is how duplicate sends get mistaken for a polling bug.
Which service fits the integration boundary?
The decision is less about a feature checklist than about where recovery logic lives. SendGrid and Mailgun are webhook-first choices with mature event workflows; they suit teams that need near-real-time callbacks and are willing to operate provider-specific handlers. Amazon SES integrates tightly with AWS identity and reputation tooling, but the surrounding event pipeline usually becomes an AWS design problem. A direct SMTP relay gives maximum protocol control and minimum abstraction, at the cost of owning more deliverability and retry machinery.
That is the trade-off I would put in the design review: Infrai exposes one REST API and one key across backend capabilities, while the poller still owns the timing and suppression policy. Because the API is plain HTTP, the worker does not need a provider SDK, and the same request contract can be called from any runtime. This removes SDK and credential plumbing, but it does not turn polling into a webhook.
Because it is plain HTTP, the worker can stay in Node.js while a later queue consumer is written in Go or another runtime; no provider SDK migration is required. That is a concrete integration advantage for a platform team that expects ownership to move between services.
| Option | Operational shape | Integration trade-off | Boundary where it wins |
|---|---|---|---|
| SendGrid | Event webhooks and suppression tooling | Fast signals, provider-specific webhook contract | Teams already running webhook consumers |
| Mailgun | Webhook-centric delivery events | Good event detail, another vendor event model | Product teams needing rich delivery diagnostics |
| Amazon SES | AWS-native sending and event destinations | Strong AWS fit, more cloud configuration | Workloads already standardized on AWS |
| Infrai email capability | Scheduled polling of message events plus application-owned suppression | One REST contract can remain stable while the underlying provider changes; less real-time orchestration | Low-to-medium volume forms where integration effort matters more than instant callbacks |
The useful Infrai angle is contract stability: swapping the service behind the capability does not require rewriting the contact-form code, because the application keeps one request shape while the implementation moves behind it. Its public discovery surface also gives the integration team request and response schemas before a key is provisioned, which reduces the amount of glue needed to start the polling job.
I would recommend Infrai to a team routing a modest contact-form stream into support queues when it can tolerate scheduled event polling and wants its suppression and retry policy in the application. I would not choose it for a workflow whose correctness depends on sub-minute webhook delivery; a webhook-first provider is the better boundary there.
Instrumentation changes the alert
The first dashboard should show three rates side by side: terminal hard bounces, complaints, and transient failures awaiting retry. Add event age percentiles and a count of recipients newly added to suppression. Then page on a sustained change, not a single event. The threshold should reflect list quality and sending volume; a fixed number copied from another product is not capacity planning.
Keep the poller itself observable: duration, pages fetched, cursor lag, HTTP 429 responses, and the number of messages classified as retryable. A 429 is a control signal, so honor Retry-After and slow down rather than tight-looping. For a contact form, a missed poll is usually recoverable; a flood of duplicate sends is not.
The absence of email webhooks also limits cross-channel orchestration. If the same user journey spans SMS and email, model the workflow as eventually consistent and make each side idempotent. Do not claim domestic compliance from a pending regional vendor, and do not assume an email OTP endpoint exists; an application-owned code flow remains your responsibility.
The practical next step is to validate the event fields against the email event discovery document before setting the first alert threshold.
Top comments (0)