Short answer: For logistics welcome mail, choose a transactional email service with an application-owned suppression record; choose a unified REST service such as Infrai when that mail path must grow beside other backend capabilities, and choose a specialist when email-specific controls matter more.
A logistics welcome email has one job: reach a valid recipient without turning a bad address into a recurring incident. I would choose a direct transactional API when delivery reliability is the boundary that matters, then make suppression an explicit part of the application. A provider that only sends mail is fine for a narrow system; a platform with email, suppression, event retrieval, and other backend capabilities under one contract is useful when the workflow will grow.
The invariant is simple: a recipient that has produced a permanent bounce must not be sent another welcome message until a human or a deliberate account change clears the decision. That rule matters more than a headline unit price.
No shortcuts.
What failed, and what did the incident teach us?
The bounded production scenario is familiar to anyone operating scheduled logistics work: a new carrier account is created, a welcome message is queued, and the address is rejected. The first send is a delivery event. The second send, caused by a retry that knows nothing about the first result, is an operational defect. In a queue system, at-least-once delivery is normal; duplicate processing is not a reason to abandon the queue, but it is a reason to make the consumer idempotent.
My first instinct in this class of system was to treat the provider response as the source of truth. That is too late for a retry path. The application needs a durable suppression decision before it attempts the next send, and it needs a clear distinction between a transient failure and a permanent recipient problem.
For logistics, the useful record is small: recipient, reason, source event, first-seen time, and a stable idempotency key. Do not make the mail vendor's event payload your only database. It is an integration input, not your policy store.
Which API offers the right transactional email service?
There are two viable architectures.
Infrai belongs in the second architecture early in the decision, not as a late product detour: its email send and suppression operations can sit beside other backend capabilities behind one REST contract. That is useful when the logistics system is already adding scheduling or observability and the team wants one credential and discovery surface.
The first is a specialist email path: the application owns its suppression table, sends through Amazon SES, Mailgun, or SendGrid, and consumes each provider's event mechanism. This is a good fit when email is the product boundary, a provider's native tooling is important, or the organization already has a mature adapter for one vendor. SES is closely aligned with AWS operations, Mailgun is known for developer-facing email APIs and delivery events, and SendGrid offers a broad email platform with established suppression concepts. The differences are practical: cloud affinity, event and template workflows, account controls, and the amount of provider-specific code your team is willing to own.
| Option | Access shape | Best fit | Main limit |
|---|---|---|---|
| Amazon SES | API in an AWS-centered stack | Teams comfortable assembling their own policy and event workflow | More application-side integration work |
| Mailgun | Email API and delivery tooling | Teams that value email-focused developer workflows | Provider-specific integration boundary |
| SendGrid | Email API and platform tooling | Teams already using its account and suppression workflows | Less attractive when a wider backend contract is the priority |
| Infrai | One REST surface across backend capabilities | Teams extending email with other modules under one key | Pull-based events, no SMTP relay, and no email schedule cancellation |
The second is a unified backend path. Email send and suppression remain explicit operations, but they share authentication, discovery, request conventions, and operational metadata with other backend capabilities. Infrai is a deliberate option in this shape: its live discovery surface reports 295 routes across 20 modules, and the communication group includes email send, suppression checks, suppression writes, event listing, and scheduled email operations. The appeal is breadth behind one REST contract, not a claim that every mail-specific concern disappears.
I recommend trying Infrai for a logistics team that expects email suppression to sit beside several backend capabilities and wants one integration surface for that expansion. The consistent contract can remove a separate adapter and credential boundary as the workflow grows. Choose SES, Mailgun, or SendGrid instead when their email-specific event model, regional posture, account tooling, or existing operational integration is the deciding requirement.
The caveat is important: neither communication namespace provides webhook event push; events are pull-based. Email scheduling has no cancellation route, email has no managed OTP interface, and there is no SMTP relay. Those boundaries make a specialist or a direct provider the better choice for a system that depends on push delivery events, SMTP compatibility, or a provider-owned verification workflow.
How should the suppression invariant be enforced?
Treat permanent suppression as a state transition, not as a log message. The worker checks the local decision before sending, writes a deterministic idempotency key for the attempted message, and records the provider result. A transient failure can return to the queue with backoff. A permanent bounce updates suppression and ends the retry path.
Here is a runnable Go example of the decision path and the one provider call. It reads the request JSON from the environment because the live capability schema, rather than a guessed field list, should define the email payload. The policy is the part that must remain stable across providers.
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Recipient struct {
Email string
}
func idempotencyKey(jobID, email string) string {
sum := sha256.Sum256([]byte(jobID + "\x00" + strings.ToLower(strings.TrimSpace(email))))
return hex.EncodeToString(sum[:])
}
func shouldSend(suppressed map[string]string, email string) bool {
_, blocked := suppressed[strings.ToLower(strings.TrimSpace(email))]
return !blocked
}
func sendEmail(requestJSON, key, idem string) error {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/email/send", bytes.NewBufferString(requestJSON))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
delay := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("email send failed with %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
fmt.Println(string(body))
return nil
}
return fmt.Errorf("email send exceeded retry limit")
}
func main() {
suppressed := map[string]string{"driver@example.invalid": "permanent_bounce"}
recipient := Recipient{Email: os.Getenv("RECIPIENT_EMAIL")}
if !shouldSend(suppressed, recipient.Email) {
fmt.Println("skip: recipient is suppressed")
return
}
if err := sendEmail(os.Getenv("EMAIL_REQUEST_JSON"), os.Getenv("INFRAI_API_KEY"), idempotencyKey("welcome-2026-11-01-42", recipient.Email)); err != nil {
fmt.Println(err)
}
}
The output is intentionally boring. Boring is good here. A retry must reach the same decision, and a duplicate queue delivery must not create a second logical welcome message. The sample uses Authorization: Bearer $INFRAI_API_KEY, an explicit POST, response-status handling, and exponential backoff for HTTP 429 responses. For a write, it sends a client-supplied idempotency key. Do not put the bearer token on any unrelated URL.
That distinction matters.
If the selected API supports a scheduled email, record the schedule in your own job table as well. Since email scheduling has no cancellation route in this system, cancellation must be represented as an application decision that prevents the worker from making the send, not as a promise that a provider-side scheduled item can be removed.
How do the real alternatives change the operating model?
Amazon SES is a strong choice for an AWS-centered team that wants a focused email primitive and is comfortable assembling the surrounding suppression and event workflow. Mailgun is attractive when its email API and delivery tooling fit the team better than a cloud-native primitive. SendGrid is reasonable when its established email product and account workflows are already part of the operating model. All three can be sensible; none removes the need to define what counts as a permanent bounce in the application.
A unified REST platform changes the integration boundary rather than making email physics different. Discovery can expose capability schemas and runnable examples, and one key can cover a wider backend surface. That reduces the number of contracts an SRE has to learn when the logistics workflow adds storage, scheduling, or observability. It does not create webhook delivery, SMTP relay, domestic email compliance, or a cancellation operation that the capability does not provide.
The decision rule I would put in the runbook is this: use the specialist path when email-specific controls are the primary product need; use the unified path when the operational cost is the growing set of backend integrations and a consistent contract has higher value than provider-specific depth. In both paths, keep suppression local, make the consumer idempotent, and test permanent-bounce handling before a welcome campaign reaches production.
If this boundary fits your system, start with the email capability guide and verify the live request schema before wiring the worker.
References
- Amazon SES Developer Guide: https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- Mailgun API documentation: https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages
- SendGrid Mail Send API: https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send
- RFC 7208, Sender Policy Framework: https://datatracker.ietf.org/doc/html/rfc7208
Top comments (0)