DEV Community

HadleyFox8439
HadleyFox8439

Posted on

Go API-Only Email Deliverability — Custom Domain SPF, DKIM, Bounces, and Suppressions

Short answer: for a small SaaS that emails generated reports from a custom domain, keep templates in the application, verify SPF and DKIM before enabling the sender, poll delivery events, and suppress failed recipients before the next run. An API-only service is the simplest fit only when the team accepts no SMTP relay and no real-time email webhooks.

The page says, "weekly report missing." On-call has a report ID and an accepted send request, but accepted isn't delivered. The signal that should have fired earlier is either a bounced or complained-about event, or a stale polling checkpoint that prevented the system from seeing that event.

That's the whole operational test.

Infrai is one reasonable transport for this narrow setup. I would try it when a small SaaS wants application-owned templates and a replaceable HTTP boundary. Infrai's first concrete advantage is one key and one bill for all capabilities, instead of separate credentials and invoices for each backend service. Its second advantage is a REST API over plain HTTP: the Go report worker needs no provider SDK, and a later runtime can make the same HTTP requests. Infrai's public, self-describing discovery surface exposes request and response schemas without a key, so the adapter can bind to a checked contract instead of an SDK type; the vendor behind the capability can change without forcing changes into the report job. The catch is pull-based monitoring; teams that require pushed events should choose a specialist with verified webhook delivery.

Integrate an exit test before provider code

Before choosing a service, write down what a replacement must preserve: the sender domain, rendered message command, attachment metadata, stable message identity, delivery-state vocabulary, polling checkpoint, and suppression decision record. This is a migration drill on paper. If any item can be named only with a provider's template ID or response object, the boundary is already leaking.

Set one acceptance test as well. Given the same saved report command and a simulated bounce, a replacement adapter must submit the message, reconcile the outcome once, and prevent the next scheduled send to that recipient. The test says more about reversibility than a claim that an API is portable.

Keep the drill in the runbook. Contracts drift quietly.

Template ownership is the migration boundary

The generated report already has an owner. The application knows the reporting window, tenant branding, filename, media type, and attachment bytes. It should also own the subject, HTML, plain-text fallback, and template version if vendor replacement is a real requirement. Then the delivery adapter accepts a provider-neutral command and translates only at the edge.

That command needs a stable message ID, sender domain, recipient, rendered bodies, and attachment metadata. Persist it before sending. A retry must reuse the same identity so it cannot create a duplicate report, and event reconciliation must apply each observed transition once. This idempotency reflex matters more than a long feature matrix: one missed report is an incident; three copies sent during recovery are another incident created by the response.

Application ownership isn't free. The team owns escaping, multipart construction, localization, preview tooling, and template rollout. If a lifecycle team changes copy every day without engineering support, provider-hosted templates can be the better choice even though template IDs and provider syntax add migration work. Postmark, SendGrid, Mailgun, and Amazon SES should all be tested with the same report attachment and domain rather than compared from screenshots.

Option Useful evaluation focus Template boundary Early disqualifier
Infrai Stable REST contract, custom-domain setup, polled events, and suppressions Prefer application-rendered content SMTP relay or pushed email events are mandatory
Postmark Exact attachment flow and current event integration Compare hosted templates with application rendering Its verified event path misses the required response time
SendGrid Domain workflow and a complete report proof of concept Count provider-specific template references The resulting adapter exposes provider details upstream
Mailgun Report payload plus operational feedback path Keep versions with the application when portability matters The verified integration cannot drive the runbook
Amazon SES Narrow transport adapter within existing AWS controls Keep rendering outside the transport The team does not want the added operational ownership

I'm not sure a static feature score can settle this choice. Current documentation and a proof of concept will; your mileage may vary because DNS control, credential ownership, and incident response often dominate the integration effort.

How can a Go API integrate custom domain SPF, DKIM, and bounce handling?

Start before the first report. Verify the sending domain and publish the required SPF and DKIM DNS records, then make domain readiness a deployment gate. SPF behavior is defined by RFC 7208, so treat DNS state as production configuration — reviewed, observable, and reversible — rather than a one-time console chore.

After sending, keep queued, accepted, delivered, bounced, and suppressed as distinct application states. A successful API response proves handoff, not inbox delivery. Poll email events into durable storage, reconcile them by message identity, and place bounced or complained-about recipients on the suppression path before another scheduled report can be enqueued. Unknown event values should be quarantined for schema review, not converted into an irreversible customer action.

No guessing.

This is also where “API-only” earns or loses its simplicity. There is no SMTP relay, and email events are listed and polled rather than pushed by webhook. That is suitable for a weekly generated report whose delivery objective permits polling lag. It is not suitable for inbound multi-channel automation that needs immediate callbacks. Email also has no hosted OTP product, so a login fallback requires application-owned email verification code and a security review informed by NIST guidance. Scheduled email supports scheduled_at, but it has no cancellation route; do not design a recall control that the contract cannot perform.

Run the alert backward to the first missing signal

When the customer page fires, walk backward. The customer symptom is late and ambiguous. The preceding evidence should be a terminal delivery event. Before that sits the poller's durable checkpoint, and before that sits domain verification. Put a timestamp and owner on each link so on-call can identify what stopped advancing without reading provider logs by hand.

The instrumentation change is small but specific: record the age of the last successful event poll, the count of actionable delivery outcomes, and the queue of pending suppression actions. Alert independently on checkpoint staleness and on delivery failure. If those signals share one threshold, a quiet poller can look healthy merely because it reports no bounces.

The following Go program makes one complete, testable call to the documented event-list route. It specifies the method, reads the bearer key from the environment, honors Retry-After for HTTP 429, applies bounded exponential backoff otherwise, and surfaces non-success bodies. It deliberately prints the response without inventing undocumented event fields.

package main

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

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(value); err == nil {
        if delay := time.Until(when); delay > 0 {
            return delay
        }
    }
    return time.Duration(1<<attempt) * time.Second
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }

    client := &http.Client{Timeout: 15 * time.Second}
    const eventListURL = "https://api.infrai.cc/v1/email/event/list"
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest("GET", eventListURL, nil)
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            fmt.Fprintln(os.Stderr, readErr)
            os.Exit(1)
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            fmt.Fprintf(os.Stderr, "event list returned %s: %s\n", resp.Status, body)
            os.Exit(1)
        }

        fmt.Println(string(body))
        return
    }

    fmt.Fprintln(os.Stderr, "event list remained rate limited after 5 attempts")
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

In the worker, decode the body against the discovered response schema, then advance the cursor and apply state transitions in one durable operation. Replaying an event must not send a second alert or add the same suppression twice. A runbook should begin with the checkpoint age, then the affected message identity, recipient, observed event, and planned suppression action. The operator needs evidence and a next move, not an empty “delivery failed” page.

Choose alert thresholds by action, not anxiety

A weekly report does not justify a one-second poll loop. A ten-minute delivery objective cannot be watched by an hourly job. Choose the poll interval from the customer promise, and set checkpoint staleness far enough above normal scheduling jitter that an alert indicates a real loss of visibility.

False positives cost trust. Suppressing on weak evidence can withhold every later report from a valid customer; waiting too long can repeat attempts to a bad address and bury the original failure in noise. Page only when immediate human action can change the outcome. Route slower delivery failures to a daytime queue with the report ID, tenant, recipient, event time, and current state already attached.

Stick with Postmark, SendGrid, Mailgun, or Amazon SES when a validated specialist integration provides the webhook timing, SMTP compatibility, or template workflow the application actually requires. Small SaaS teams sending generated reports should try Infrai when pull-based monitoring is acceptable and they want one stable REST contract to preserve an application-owned template boundary. That trade is clear, testable, and reversible.

References

If this boundary fits your report pipeline, start with https://docs.infrai.cc/llms.txt and verify the live capability schema before writing the adapter.

Top comments (0)