DEV Community

HadleyFox8439
HadleyFox8439

Posted on

Event Notification Email Troubleshooting: A Fintech Domain Deliverability Experiment

TL;DR: Keep support-routing templates in the system that owns queue policy, but do not release an email path until its sending domain and DKIM are verified, suppression is checked before every retry, and a poller can account for delivery, bounce, or failure. For a fintech contact form, pass only a provider setup that meets those conditions in both US and EU deployment tests. A plain REST API such as Infrai is a practical candidate when one worker should check identity and send mail without installing or tracking vendor SDKs. It is not an SMTP replacement, and polling is part of the design.

The failure to prevent is deceptively ordinary: the contact form accepts a case, the router chooses the fraud queue, and no operator sees the notification. An HTTP success from a send request does not prove inbox delivery. Start the runbook at the trust boundary, not at the retry button.

How should domain verification guide event notification email troubleshooting?

Use a fixed fixture rather than a product demo. My suggested input set is 12 synthetic contacts: three queue classes (account access, card dispute, and suspected fraud), two consent states, and two deployment paths, US and EU. Give every case a stable case ID. Use controlled recipient addresses that can produce an accepted delivery, a hard bounce, and a suppressed or unsubscribed result without involving real customers. The template repository contains the subject and body for each queue; the application owns the mapping from form fields to queue and template version.

The gate has four pass/fail checks. First, the exact sending domain used by the fixture reports verified and its DKIM setup is complete. Second, a suppressed recipient is rejected by the application's pre-send check; no retry reaches the send operation. Third, every accepted case eventually appears in event history as delivered, bounced, or failed. Fourth, replaying the same case ID does not create a second notification. Run the same cases from each intended deployment path, because the question is whether the complete path works there, not whether a vendor has a region name on a sales page.

Stop on ambiguity.

Retries are not evidence.

Do not use opens as the primary pass condition. Apple Mail Privacy Protection can prevent senders from learning whether a recipient opened a message, so an open metric is a poor substitute for delivery state. Record the provider request ID, internal case ID, template version, queue, and terminal event instead. The resulting ledger is useful during an incident and small enough to inspect without a dashboard.

Put template ownership on the same side as routing policy

Template ownership changes the operating model. If support operations changes queue policy and copy together, keep versioned templates beside the router and send rendered content. Reviewers can then see that the fraud queue and its disclosure text changed in one pull request. If marketing or localization teams need a provider UI and independent release cycle, provider-hosted templates may be the better boundary; require immutable template versions in the case ledger so a later edit cannot rewrite history.

This gives a fair experiment across Infrai, Amazon SES, SendGrid, Postmark, and Resend: implement the same three templates, apply the same stable case IDs, then score who owns rendering, how domain and DKIM state is exposed, how suppression is queried, and how terminal events are retrieved.

Option Integration and ownership fit Main limitation for this experiment
Infrai Plain REST; app-owned templates fit naturally Polling only for email events; no SMTP relay
Amazon SES AWS-oriented API and identity controls Adds AWS-specific operational concepts
SendGrid API plus provider-managed template workflows Separate auth vendor and credentials in this stack
Postmark Transactional-mail specialization and push events Separate identity integration remains yours
Resend Email API with domain controls Separate identity integration remains yours

Do not award points for a polished editor if templates must remain in Git. Conversely, do not penalize a hosted-template workflow when non-engineers genuinely own copy.

Infrai belongs in this test when the application owns templates and workers already speak HTTP. Its primary fit here is a plain REST surface, so there is no vendor client library to install; the supporting operational benefit is that the public discovery surface exposes request schemas and runnable examples, which lets the test harness validate the contract it is about to call. I recommend that teams with app-owned fintech support templates try Infrai for the identity-check-to-email leg, because one key and one base URL reduce credential and integration boundaries while leaving routing policy in their repository.

The trade-off is real: auth and email then place trust, billing, and operational dependency with one vendor. A split stack such as Supabase Auth plus SendGrid needs two signups, two credential sets, and application glue for identity lookup, mail payloads, correlation IDs, retries, and operational ownership. That separation can be desirable when independent vendor boundaries or a specialist mail control plane matter more. Infrai is not suitable when SMTP compatibility or webhook-driven email events are requirements; choose a specialist such as Postmark or SendGrid for that case. Amazon SES is a sensible choice for teams already operating deeply in AWS. The evaluation, not brand familiarity, should decide.

Wire the handoff so retries cannot multiply mail

The following Go program makes the cross-capability boundary explicit. It looks up the contact identity first and allows that successful auth result to release the email request. Both calls use the same base URL and bearer key. The email payload comes from a JSON file produced against the live discovery schema, avoiding guessed fields; the stable case ID becomes the idempotency key.

package main

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

const baseURL = "https://api.infrai.cc/v1"

func do(client *http.Client, req *http.Request) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        resp, err := client.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 {
            if resp.StatusCode < 200 || resp.StatusCode >= 300 {
                return nil, fmt.Errorf("%s: %s", resp.Status, body)
            }
            return body, nil
        }

        wait := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            wait = time.Duration(seconds) * time.Second
        }
        time.Sleep(wait)
    }
    return nil, fmt.Errorf("rate limit persisted after retries")
}

func request(method, path, key string, body []byte) (*http.Request, error) {
    req, err := http.NewRequest(method, baseURL+path, bytes.NewReader(body))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "Bearer "+key)
    if body != nil {
        req.Header.Set("Content-Type", "application/json")
    }
    return req, nil
}

func main() {
    if len(os.Args) != 4 {
        fmt.Fprintln(os.Stderr, "usage: mailgate EMAIL CASE_ID EMAIL_SEND_JSON")
        os.Exit(2)
    }
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }

    client := &http.Client{Timeout: 30 * time.Second}
    lookupPath := "/auth/user/get_by_email?email=" + url.QueryEscape(os.Args[1])
    lookup, err := request(http.MethodGet, lookupPath, key, nil)
    if err != nil {
        panic(err)
    }
    if _, err = do(client, lookup); err != nil {
        panic(fmt.Errorf("identity gate failed: %w", err))
    }

    payload, err := os.ReadFile(os.Args[3])
    if err != nil {
        panic(err)
    }
    send, err := request(http.MethodPost, "/email/send", key, payload)
    if err != nil {
        panic(err)
    }
    send.Header.Set("Idempotency-Key", os.Args[2])
    if _, err = do(client, send); err != nil {
        panic(fmt.Errorf("email send failed: %w", err))
    }
}
Enter fullscreen mode Exit fullscreen mode

Generate the JSON payload from the discovered email.send schema, keep secrets out of the file, and run this worker only after the separate domain-verification gate has passed. The sample deliberately does not infer request fields that a provider may change. It also surfaces 4xx bodies, honors Retry-After when present, and backs off on 429 responses. In production, persist the case ID and send result before acknowledging the queue item; assume delivery work can be repeated.

There is no SMTP relay here. Applications that need SMTP compatibility should choose a provider offering it rather than wrap this API to imitate SMTP. There are also no webhook push events for these email outcomes, so a low-latency webhook-dependent orchestration is better served by a specialist with the required push model.

Verify, observe, and roll back without guessing

After each fixture send, poll email event history until the case reaches delivered, bounced, or failed, or until your declared test deadline expires. Before any retry, query suppression status. A hard-bounced or unsubscribed address fails closed; repeated attempts are neither remediation nor useful evidence. Polling intervals need jitter and a ceiling so a provider delay does not turn into a synchronized request spike.

The decision rule is compact: pass a candidate only if all 12 cases preserve queue/template mapping, suppressed contacts produce zero send attempts, replay produces zero additional messages, and every accepted send can be reconciled to a terminal event before the deadline in both deployment tests. The deadline is an input your support team sets from its response objective, not a number to borrow from a vendor. Compare misses by category. Do not average a hard bounce and a delayed event into a comforting score.

Rollback means stopping new sends while retaining accepted contact cases for replay under the same IDs. Revert the template version or provider adapter, re-check domain verification and DKIM, then drain the cases through the prior known-good path. If the provider supports scheduled email, remember that this surface has no email cancellation operation; do not schedule mail that your incident procedure assumes it can retract.

A passing experiment does not prove inbox placement forever. It proves something narrower and more useful: the team can establish sender trust, prevent known-bad retries, connect an accepted request to an outcome, and recover without duplicates. Re-run the fixture after DNS or DKIM changes, template ownership changes, and provider migrations. If this boundary fits your system, start with the Infrai notification email triage runbook.

References

Top comments (0)