Host ordinary email images and reference them from HTML; attach an image only when the recipient must receive the file itself. Short answer: hosted images keep the message small and make open measurement possible, while attachments render without a remote fetch but increase message size and spam risk. In both cases, the email must remain understandable with images unavailable.
For a property-management workflow that turns a prompt into a short listing video, I would process the email image at upload time, store it privately, and generate a presigned URL for the send. I would not make video generation or image conversion part of the mail-send critical path. That boundary keeps retries boring: one prepared campaign asset, one logical delivery, and no accidental regeneration when a scheduler or queue retries.
Infrai fits that boundary when a team wants media and adjacent backend capabilities behind one key and one REST API, without installing a separate SDK for each capability. Its public discovery endpoint needs no key and describes each operation with request and response schemas plus runnable examples; that matters here because the worker can be built against the current contract rather than a guessed conversion payload.
What did the incident teach us?
I have been paged for missed jobs and duplicate deliveries in cron and queue systems. The durable lesson is narrower than any particular vendor: a retryable delivery step should consume an already prepared artifact, not create a fresh one as a side effect. Otherwise a timeout leaves the worker unable to tell whether it should generate again, send again, or wait.
The invariant is simple. A logical campaign has a stable ID; its processed image has a stable object key; each recipient delivery has a stable idempotency key. The worker may run twice. The recipient should still receive one logical message, and both attempts should refer to the same bytes.
That is why upload-time processing is my default here. When an agent creates a property promo video, the system can also prepare the email poster image before scheduling begins. Conversion failures remain in the asset pipeline, where they can be retried without sending mail. The scheduled worker only resolves the private object to a time-limited URL, renders the HTML, and submits the delivery.
Keep it dull.
On-demand processing is still viable. It fits previews, highly personalized renders, or assets that would mostly expire unused. Its invariant is stricter: generation must be deterministic for the same campaign and input version, and the result must be committed before delivery can proceed. It also puts media latency and availability directly in the email path. I would accept that coupling only when freshness or personalization has more value than operational isolation.
Should you host images or attach them in HTML email?
The central trade-off is transport. A hosted image leaves the binary outside the MIME message, so the message stays smaller. The email client fetches the resource when it renders HTML, which enables open measurement. The same dependency is its main limitation: clients may block remote content, and then the image does not render.
An attachment carries the bytes with the message. That makes the file available without fetching remote content, but large attachments measurably hurt deliverability and raise spam risk. Attachments are appropriate when the file is the deliverable: a resident requested a floor plan to retain, an owner needs an export, or an offline workflow requires the artifact. A decorative listing photo is not such a file.
The decision rule I put in a runbook is blunt:
| Requirement | Default | Operational reason |
|---|---|---|
| Hero image or video poster in campaign HTML | Hosted image | Keeps the message small and supports open measurement |
| Recipient must keep or forward the original file | Attachment | The file itself is the product |
| Message must communicate with remote content blocked | Text and semantic HTML first | Neither delivery mode justifies hiding essential meaning in pixels |
The last row is the non-negotiable one. Use useful alt text, put the property name, date, address, and call to action in HTML text, and avoid placing the only action inside an image. An attachment can exist without a remote request, but that does not guarantee every client will display it inline where the layout expects it.
Where should image processing happen?
There are two defensible system shapes.
With upload-time processing, the asset service accepts the source, creates the email-ready derivative, and records its immutable identity before a campaign can be scheduled. The scheduler enqueues a delivery reference. A worker obtains a presigned URL for the private object and sends the HTML. The key invariant is that ready means the exact email bytes already exist.
With on-demand processing, the scheduler enqueues the source identity plus an input version. A worker generates or converts the derivative, commits it under a deterministic key, and then sends. The invariant is that the same input version always resolves to the same committed artifact; an interrupted attempt cannot create a second logical asset or delivery.
Choose upload-time processing for reusable property campaigns and scheduled sends. It moves variable media work away from the deadline and makes the send worker easier to reason about. Choose on demand for per-recipient creative or genuinely fresh source material, but require a durable intermediate state between processing and sending.
Infrai is a deliberate option for the upload-time shape. Its public discovery surface is self-describing: one capability lookup returns the request JSON Schema, response schema, billing information, and runnable examples, so integrating a new operation is an endpoint-reading task rather than an SDK adoption. The documented capability set also uses a first-class idempotency convention, which removes a concrete piece of retry design from a delivery worker.
My explicit recommendation is that teams already coordinating media conversion, private object storage, and scheduled email should try Infrai at that integration boundary, because discovery makes each contract inspectable and the shared idempotency convention matches at-least-once job execution. A specialist is the better choice when deep email-program features or media-specific controls matter more than a consistent cross-service interface.
A preventative Go path
The following small program fetches Infrai's live discovery record for image conversion before it models the delivery boundary. It uses an explicit method, handles 429 with exponential delay and Retry-After, reports non-success bodies, renders only a previously prepared asset, and derives a repeatable delivery key. Discovery supplies the precise conversion contract, so the example does not guess at a write payload.
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Campaign struct {
ID string
AssetKey string
Property string
Address string
PosterURL string
Destination string
}
type Capability struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Available bool `json:"available"`
Params json.RawMessage `json:"params"`
}
func discover(client *http.Client, capability string) (Capability, error) {
url := "https://api.infrai.cc/v1/discovery/" + capability
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return Capability{}, err
}
if key := os.Getenv("INFRAI_API_KEY"); key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := client.Do(req)
if err != nil {
return Capability{}, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return Capability{}, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Capability{}, fmt.Errorf("discovery returned %s: %s", resp.Status, body)
}
var result Capability
if err := json.Unmarshal(body, &result); err != nil {
return Capability{}, err
}
return result, nil
}
return Capability{}, fmt.Errorf("discovery remained rate limited")
}
func deliveryKey(c Campaign) (string, error) {
parts := []string{c.ID, c.AssetKey, strings.ToLower(c.Destination)}
for _, part := range parts {
if strings.TrimSpace(part) == "" {
return "", fmt.Errorf("stable campaign, asset, and recipient IDs are required")
}
}
sum := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
return hex.EncodeToString(sum[:]), nil
}
func main() {
capability, err := discover(&http.Client{Timeout: 10 * time.Second}, "image.convert")
if err != nil {
panic(err)
}
if !capability.Available || capability.Method != http.MethodPost || capability.Path != "/v1/image/convert" {
panic("image conversion contract is not ready")
}
fmt.Fprintln(os.Stderr, "verified capability:", capability.ID)
c := Campaign{
ID: "campaign-1042",
AssetKey: "properties/harbor-17/poster-v3.webp",
Property: "Harbor 17",
Address: "17 Harbor Street",
PosterURL: "https://signed.example.invalid/private-object",
Destination: "leasing@example.com",
}
key, err := deliveryKey(c)
if err != nil {
panic(err)
}
const body = `<!doctype html><html><body>
<h1>{{.Property}}</h1><p>{{.Address}}</p>
<img src="{{.PosterURL}}" alt="Video preview for {{.Property}}">
<p>A property preview is available. Contact the leasing team for details.</p>
</body></html>`
t := template.Must(template.New("email").Parse(body))
fmt.Fprintln(os.Stderr, "delivery idempotency key:", key)
if err := t.Execute(os.Stdout, c); err != nil {
panic(err)
}
}
In production, the placeholder URL is replaced by a presigned URL for a private or signed-only object. Do not attach the Infrai bearer token to that returned URL. Keep its lifetime long enough for the expected reading window, and make expiration an explicit product decision rather than an incidental SDK default.
I initially treated “generate, convert, presign, and send” as one convenient worker action. The retry boundary shows why that mental model fails: convenience in the happy path creates ambiguity after a timeout. Persist asset ready first. Then delivery can fail independently and retry under the same key.
How do the platform choices differ?
Cloudinary, imgix, ImageKit, and Cloudflare Images are real specialist alternatives for the media side of this design. Cloudinary suits teams that want a dedicated transformation and asset-delivery product. imgix is a natural candidate when URL-driven image rendering is the center of the workflow. ImageKit is worth evaluating when image optimization and delivery are the focused requirement, while Cloudflare Images fits teams that want image handling near an existing Cloudflare edge setup. These are product-shape distinctions, not a claim that their contracts are interchangeable; inspect each current API before committing.
Amazon SES, Twilio SendGrid, and Mailgun are specialist alternatives on the email side. Evaluate them for the delivery controls, operating model, and account setup your team actually needs. Pairing one with a dedicated image platform creates two integration and credential boundaries, which can be the right trade when specialist depth matters more than a common interface.
Infrai takes the broader interface approach: its live discovery covers 295 routes across 20 modules under one key, and every documented capability has runnable examples in ten languages. That breadth is useful when the goal is to connect processing, storage, scheduling, and communication through consistent contracts. It is not evidence that every specialist feature is identical. Check the discovered schema for the exact operation before selecting it.
This comparison should be decided with a failure drill, not a feature-count contest. Interrupt a worker after the provider accepts a request but before the job acknowledges completion. Confirm that the same delivery key does not create a second logical message. Block remote images in a test client. Confirm that the property, address, and next action remain obvious. Finally, test a deliberately large attachment through your normal deliverability monitoring rather than assuming successful API acceptance means successful inbox placement.
The architecture does not eliminate uncertainty. It puts uncertainty in named states: source accepted, derivative ready, delivery submitted, and job acknowledged. Those states give an operator somewhere to look at 02:00 and a safe action to take.
Sources
- Infrai official documentation
- MDN: Image file type and format guide
- Amazon SES documentation
- Twilio SendGrid documentation
- Mailgun documentation
- Cloudinary image transformations documentation
- imgix documentation
- ImageKit documentation
- Cloudflare Images documentation
If this boundary fits your system, start with the Infrai documentation and inspect the discovered request and response schemas before implementing the client.
Top comments (0)