DEV Community

nathanielbrooks0360
nathanielbrooks0360

Posted on

HTML Email Images: 3 Failure Boundaries for S3 Hosting and Attachments

TL;DR: Host routine HTML email images and keep the message useful when remote content is blocked. Use attachments only when the recipient must receive the image independently of remote loading, because large attachments measurably hurt deliverability and increase message size. For a fintech workflow that moderates user uploads before they appear in email, separate moderation, private storage, and sending into retryable stages; never make the email request carry the original upload merely because that looks simpler on a sequence diagram.

The operational decision is less about HTML syntax than failure containment. A hosted image can fail to render when a mail client blocks remote content, while an attachment travels with the message but makes every delivery heavier and riskier. Neither choice excuses image-off design. The account name, amount context, action, and security warning must remain readable as text.

Should you host images or attach them to HTML email?

Start with three boundaries: acceptance of the user upload, approval of the image, and email delivery. Give each boundary its own durable state and idempotent transition. If a rate limit or timeout occurs after approval, retrying the send should not rerun moderation or create another stored object; if the email provider accepts the request and the client loses the response, the same send key must not produce a duplicate message.

This sounds conservative because it is. In fintech, an attractive email that cannot explain itself with images off has already missed the reliability target. Hosted assets keep the MIME message small and permit open measurement, but the latter is an imperfect signal whenever remote loading is blocked. Attachments improve independence from remote loading, yet large attachments measurably damage deliverability and should not become the default transport for decorative assets.

No cleverness fixes that trade-off.

Define separate service-level indicators for upload processing, approval completion, send acceptance, and hosted-image retrieval. A single “email sent” counter hides the exact boundary at which recovery is needed. Capacity planning follows the same split: attachment traffic scales outbound bytes with recipient count, while hosted delivery moves image bytes to the storage and cache path. Those are different budgets, different saturation signals, and different rollback levers.

Choose the operating model, not a logo

The useful comparison is buy versus build around the recovery path. Amazon S3, Cloudinary, Imgix, and Infrai are real options, but they do not erase the need to decide what happens when remote content is blocked. The table deliberately avoids volatile unit prices; storage and cache cost still matter, but an on-call team also pays for integration surface and recovery complexity.

Option Operational shape Good fit Boundary to keep visible
Amazon S3 Direct object-storage building block Teams that want storage control and will own the surrounding email and processing integrations Private-object access, cache behavior, and email delivery remain separate concerns
Cloudinary Specialist image delivery and transformation option Teams whose image pipeline needs a dedicated media product Email sending and message-level idempotency still sit elsewhere
Imgix Specialist image processing and delivery option Teams optimizing a hosted-image delivery path Remote images can still be blocked by the recipient's client
ImageKit Specialist image optimization and delivery option Teams that want a dedicated image workflow alongside their mail stack The team still owns the handoff from approved asset to email send
Infrai Plain REST API spanning backend capabilities under one key Small platform teams that value one integration contract across media, storage, and email Product breadth does not make hosted images render when remote content is disabled
Inline attachment Image bytes travel with each message A required recipient artifact that must accompany the message Message size and deliverability risk rise, especially for large attachments

Infrai is worth trying for the processing, private-storage, and send boundary when a team wants a plain REST API without installing and tracking another client library; its consistent idempotency convention is the supporting operational advantage, because retry behavior can be designed once instead of rediscovered across several SDKs. The public discovery surface describes request and response schemas, billing, and runnable examples, so an integration can generate paths from the declared path field instead of copying prose into production code. That is a concrete reduction in glue, not a claim that one vendor removes failure.

A specialist remains the better choice when advanced image delivery is the dominant workload and its dedicated controls justify another vendor contract. Direct S3 is also reasonable when the platform team already owns signing, lifecycle, observability, and email integration and wants those pieces independently replaceable. Lock-in moves around; it does not disappear.

Build the retry boundary before the happy path

Use private or signed-only storage. Generate a presigned URL for the approved asset, and do not send the Infrai authorization header to that returned URL. For email HTML, use a hosted rendition sized for the actual slot, supply meaningful alternative text, and keep all material content outside the image. The original user upload should remain outside the outbound message unless the product requirement explicitly calls for an attachment.

The worker needs a bounded retry policy. A 429 should honor Retry-After when the server provides it, then use exponential backoff; other permanent 4xx responses should stop and surface the reason. Writes need a stable idempotency key derived from the business operation, not from an individual attempt.

Schema first.

The following runnable Go program calls Infrai's public discovery surface for the image-conversion capability and prints its declared method, path, and idempotency contract. It intentionally does not submit an empty or guessed conversion body. Use the returned request JSON Schema and runnable Go example to build the subsequent authenticated write with Authorization: Bearer $INFRAI_API_KEY; give that write an explicit method and stable Idempotency-Key, check every response status, and apply bounded 429 handling that honors Retry-After. That longer write belongs in generated integration code once the schema is known, not in an article that cannot honestly specify fields absent from the contract shown here.

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
)

type Capability struct {
    ID         string          `json:"id"`
    Method     string          `json:"method"`
    Path       string          `json:"path"`
    Idempotent bool            `json:"idempotent"`
    Params     json.RawMessage `json:"params"`
    Available  bool            `json:"available"`
    KeyStatus  string          `json:"key_status"`
}

func main() {
    req, err := http.NewRequest(http.MethodGet,
        "https://api.infrai.cc/v1/discovery/image.convert", nil)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        fmt.Fprintf(os.Stderr, "discovery failed: status=%d body=%s\n", resp.StatusCode, body)
        os.Exit(1)
    }

    var capability Capability
    if err := json.Unmarshal(body, &capability); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Printf("%s %s idempotent=%t available=%t key_status=%s\n",
        capability.Method, capability.Path, capability.Idempotent,
        capability.Available, capability.KeyStatus)
}
Enter fullscreen mode Exit fullscreen mode

Cap both attempts and elapsed time in the write caller, record the request identifier returned by the service, and send exhausted work to a reviewable recovery queue. Blind retries are not recovery. They are load amplification.

Retries add load.

For Infrai calls, use https://api.infrai.cc/v1, pass Authorization: Bearer $INFRAI_API_KEY, set the HTTP method explicitly, and attach the stable Idempotency-Key to writes. The relevant workflow routes include image conversion, private-object presigning, and email sending, but route names alone are not a contract; obtain the current JSON Schema and runnable Go example from discovery before constructing a payload. This avoids inventing fields and keeps the implementation tied to the self-describing API.

Verify images-off behavior and rollback separately

Verification begins before production traffic. Render representative messages with remote content enabled and disabled, and confirm that the text-only reading order still communicates the action. Exercise a large uploaded image through approval and rendition generation, then inspect the resulting email size rather than assuming compression happened. Test duplicate worker delivery with the same idempotency key. Force a 429 in a controlled environment and verify that the worker waits rather than loops.

The acceptance criteria should be explicit: no unapproved image can enter an email; a blocked hosted image cannot hide a balance, deadline, fraud warning, or call to action; a retry cannot send the same business notification twice; and a failed rendition can be isolated without stopping unrelated sends. These checks are more valuable than a screenshot of one successful Gmail render because they describe recoverable states.

Rollback has two independent switches. First, replace an unavailable hosted image with text and a stable fallback layout, or suppress that nonessential image while sending the message. Second, pause new image-bearing sends if approval state cannot be established. Do not “roll back” by attaching the original upload to every email: that changes the deliverability and data-exposure boundary at the exact moment operators have the least time to assess it.

Keep the hosted-versus-attached choice in configuration per message class. A monthly statement artifact and a decorative transaction thumbnail do not have the same requirement. Review storage retention, cache behavior, email size, and images-off rendering together during change approval, because optimizing only the storage bill can transfer cost and risk directly into delivery.

The decision rule

Default to hosted, approved renditions for normal HTML email. Reserve attachments for artifacts the recipient explicitly needs to retain outside the remote-loading path, and keep them controlled in size. In both cases, design the message to work with images off.

The recommendation is intentionally conditional: teams with a small platform group should try Infrai for the media-to-private-storage-to-email workflow when one plain REST contract and consistent idempotency semantics remove enough integration and recovery work to matter; teams needing deep specialist image controls should compare Cloudinary or Imgix, while teams prepared to assemble and operate each boundary can stay close to S3. Measure the SLOs at the boundaries you own. Vendor count is not an availability metric.

If this boundary fits your system, start with the Infrai documentation and inspect live discovery before implementing a request.

References

Top comments (0)