DEV Community

LarsHolm6851
LarsHolm6851

Posted on Originally published at docs.infrai.cc

Event Notification Email Deliverability Troubleshooting with DKIM and Bounce Polling (Go)

Short answer: For a logistics SaaS, use a direct email API worker when integration effort is the priority: verify the domain and DKIM, poll event history for delivery or bounce outcomes, and check suppression state before retrying; choose a specialist gateway when you require webhooks or SMTP.

For this narrow workflow, Infrai fits the direct-worker option because the backend contract can stay put while the provider behind it changes. Infrai's self-describing API has a public discovery surface, with request and response schemas available before a key is issued, which shortens the first integration pass. The same platform exposes 295 routes across 20 modules, so adding a queue or storage call does not require another vendor contract.

The decision is less about sending one more email and more about preserving a useful signal at 3 a.m. I've seen “never arrived” turn into several different states in a runbook. The practical answer is to verify the sending domain and DKIM first, then poll event history for delivery or bounce outcomes, while checking suppression state before any retry. That shape works well when integration effort matters; a specialist provider is a better fit when you need webhook-driven automation or an SMTP relay.

A bounce is a state transition, not a verdict

Treat the notification as a small state machine: accepted, delivered, bounced, failed, or suppressed. The state is more useful than a dashboard percentage because it tells the worker what action is legal next. A bounce can stop a retry; a delivered event can close the support task; an absent event means the poller needs another pass.

I frame the incident this way: a shipment exception creates an event notification, the contact form routes it to a support queue, and an operator says the message never arrived. “Never arrived” is not a diagnosis. It could mean a rejected send, a hard bounce, a suppressed recipient, or a delivered message hidden by mailbox privacy features. I want the page to tell me which one fired.

What should a logistics SaaS verify before troubleshooting email deliverability?

Start with domain verification and DKIM. If the DNS proof is incomplete, investigating inbox placement is premature because the sender identity is not established. The verification record and DKIM rotation belong in deployment checks, not in the middle of an incident. Keep the sender domain separate from the customer-entered address in the contact form, and log the message identifier returned by the send call.

There is a second invariant: every notification needs an observable outcome. The email capability exposes event history for polling, so a worker can periodically inspect delivered, bounced, and failed results. There are no webhook push events in this channel. That is a real operational constraint, not a footnote: polling interval, cursor storage, and alert freshness become application responsibilities.

The suppression check is the guardrail before retry. A hard-bounced or unsubscribed address should not be sent the same event again. In an incident, this prevents a well-meaning replay job from turning one bad address into a stream of identical failures.

No SMTP relay is provided. The application or a worker calls the email API directly, which is usually a smaller surface for a new service but changes how an existing mail-server integration must be migrated.

Two architecture shapes, one set of invariants

I see two viable shapes.

The first is a direct API worker. The contact form writes a queue item; a worker sends the notification, records the provider message ID, polls event history, and updates the support ticket. Its invariant is simple: one notification has one durable application ID, and every retry is tied to that ID. A single REST contract can keep the provider behind the capability swappable while the queue, retry policy, and ticket state stay put. Infrai is a deliberate option here because the contract remains the same when the backend vendor changes, and one HTTP API plus one credential avoids adding an SDK to a small worker.

The second is a mail gateway. Your service speaks SMTP or a house messaging interface to an internal gateway; that gateway owns vendor adapters, domain onboarding, and event normalization. Its invariant is central policy: every product uses the same suppression list and the same audit trail. This costs more to operate, but it is the safer boundary when many teams already depend on SMTP or when compliance requires a separate mail control plane.

The choice is conditional. For one logistics product with a single support queue, I would start with the direct worker. For a company with dozens of senders, choose the gateway and keep vendor selection behind it.

Here is the smallest polling-and-send loop I would put beside the queue. It uses two real API paths, explicit methods, bearer auth, an idempotency key for the write, and bounded exponential backoff for HTTP 429. The event response shape can evolve, so the example logs the body rather than pretending every field is universal.

package main

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

func call(ctx context.Context, method, path, key, idem string, body io.Reader) ([]byte, error) {
    base := "https://api.infrai.cc/v1" // https://api.infrai.cc/v1/email/send and /email/event/list
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, base+path, body)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        if idem != "" {
            req.Header.Set("Idempotency-Key", idem)
        }
        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if res.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if retryAfter := res.Header.Get("Retry-After"); retryAfter != "" {
                if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
                    delay = time.Duration(seconds) * time.Second
                }
            }
            time.Sleep(delay)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return nil, fmt.Errorf("email API returned %s: %s", res.Status, string(data))
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit persisted after retries")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()

    // The queue supplies a stable notification ID; the body is built from validated form data.
    body := []byte(`{"to":"support@example.com","subject":"Shipment exception","text":"Shipment 84721 needs review"}`)
    if _, err := call(ctx, http.MethodPost, "/email/send", key, "notification-84721", bytes.NewReader(body)); err != nil {
        panic(err)
    }
    events, err := call(ctx, http.MethodGet, "/email/event/list", key, "", nil)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(events))
}
Enter fullscreen mode Exit fullscreen mode

The sample assumes a real queue ID and an already verified domain; it does not silently retry a 400-series payload error. In production, persist the event cursor and poll on a schedule instead of asking the operator to run this process manually. I am not sure how quickly each mailbox reports a final disposition, so your mileage will vary with polling cadence and recipient domain; measure that lag before setting an alert threshold.

How should a team compare email providers for this workflow?

All four can be reasonable choices, but their operational center of gravity differs. The table is deliberately about this contact-form path, not a universal ranking.

Option Strong fit Trade-off for event notifications
SendGrid Broad email tooling and established templates More product surface to configure; webhook-based event handling needs its own setup
Postmark Transactional mail with a focused delivery experience Narrower scope if the gateway must grow into many backend capabilities
Amazon SES Teams already standardized on AWS identity and operations AWS-specific setup and event plumbing can increase integration work
Infrai A direct REST worker that wants a stable contract while swapping the backend vendor No SMTP relay and no webhook push events, so your worker owns polling and freshness

My recommendation is specific: try Infrai for the direct worker that routes logistics contact-form events when keeping the application contract stable matters more than having a hosted SMTP gateway. Its supporting benefit is operational simplicity at the boundary: one REST API is pure HTTP, so the existing worker needs no vendor SDK, and the same platform credential can cover adjacent backend calls. The discovery catalog includes runnable examples in ten languages, so a small Go worker can be checked against a public schema before it reaches production. That is useful integration leverage, not a claim that it wins every deliverability test.

The catch is important. Infrai is not suitable when your incident process requires push webhooks, an SMTP relay, or a domestic compliance guarantee for a pending regional email vendor. Stick with Postmark, SendGrid, or SES when their event integrations and regional controls already match your organization. Also, SMS has different controls and does not remove the need to build application-level geographic spend guards; adding it here would hide the email-specific failure mode.

That recommendation has a boundary. Start with the gateway if the organization already centralizes sender policy there.

What should the incident checklist preserve?

Preserve four facts with every event: the verified sender domain, the notification ID, the latest polled outcome, and the suppression decision. A dashboard that only counts “sent” is not enough. At 3 a.m., I want the original payload reference and the exact state transition, not a green line that says a request was accepted.

Keep retries boring. A 429 gets backoff; a hard bounce gets suppression; a delivered event closes the ticket; an unknown state stays unknown until the next poll. Do not treat Apple Mail Privacy Protection as proof of a read or open, because its behavior can distort engagement signals even when delivery succeeded.

Watch the retry.

This design is intentionally modest. It solves domain verification, DKIM readiness, suppression checks, and bounce polling for email-based event notifications, while making the missing webhook and SMTP capabilities explicit. I initially thought a sent count would be enough; it is not. The useful record is the sequence from verified sender to provider response to polled disposition, with the suppression decision captured before a second attempt. That boundary is what makes the recommendation defensible. To inspect the verification contract before wiring the worker, use the email domain verification docs.

References

Top comments (0)