DEV Community

RivenPulse5812
RivenPulse5812

Posted on

4 Tests to Host or Attach Images in Logistics HTML Email

TL;DR: Host user-uploaded logistics images for routine HTML email. The message stays smaller and hosted assets can support open measurement, but remote-image blocking is real. Embed an attachment only when the recipient must see the image without fetching remote content, and accept the larger message plus higher spam risk. Either way, the shipment update must remain useful with images disabled.

Test Hosted image Embedded attachment
Message size Keeps image bytes out of the message Adds image bytes to the message
Remote content blocked Image may not render Image renders with the message
Deliverability pressure Avoids a large attachment Large attachments measurably hurt deliverability
Open measurement Can support measurement through remote requests Does not provide that hosted-image path

Recommendation: for a logistics workflow that moderates uploaded package photos before they go live, process at upload time and host the approved derivative. Put descriptive status, tracking details, and the primary action in HTML text. Use an embedded attachment for a narrow offline-viewing requirement, not as the default transport.

1. Should You Host Images or Attach Them in HTML Email?

The first test is practical: can the recipient understand the shipment event when every image is missing? A hosted proof-of-delivery photo may be blocked because an email client refuses remote content. An embedded image avoids that fetch, but the surrounding email still needs sensible text for accessibility and for clients that handle HTML oddly. Images cannot carry the status message.

For logistics mail, make the subject and HTML say what happened: delivered, exception recorded, or inspection required. The photo supports that claim. It does not replace it. This rule also stops a moderation decision from becoming a rendering decision. An approved asset can be shown; a rejected asset never reaches either delivery path.

Process at upload when the same approved derivative will appear in a customer portal and in many transactional messages. It puts moderation before publication and keeps email generation boring. On-demand processing fits rarer, one-off transformations, but it adds work to the send path and makes that path responsible for media readiness. I benchmark time-to-first-send, yet I care more about removing an avoidable dependency from the hot path.

That is the boundary.

2. Count bytes before counting features

The second test is message weight. An attachment carries the image inside every message, so larger files directly enlarge the payload. Large attachments measurably damage deliverability. Hosted images keep those bytes outside the email, although the recipient's client must fetch them later.

Do not turn this into a synthetic contest with a single magic threshold. Measure the actual templates and actual derivatives. For each candidate, record the encoded message size, number of remote image requests, image dimensions, and whether the text-only reading still completes the task. Repeat with images enabled and disabled. A 20 KB icon and a multi-megabyte inspection photo are not the same engineering choice.

Format selection belongs upstream. MDN's image format guide is a useful check on compatibility and compression characteristics; the correct derivative depends on the source and the clients you support. Keep the original private. Email gets a purpose-built derivative, not an unrestricted upload.

The vendor options differ here. Cloudinary and Imgix focus on hosted image delivery and transformation, so they are natural choices when responsive derivatives and media delivery dominate the system. ImageKit combines transformation and delivery tooling and fits teams that want that media-specific layer without adopting a broader backend surface. Amazon S3 is a lower-level object store; it gives you storage primitives, while moderation, transformation, URL policy, and email integration remain application work. None of those choices makes remote-content blocking disappear.

3. Keep the processing to delivery handoff inspectable

A small orchestration layer is enough. The example below calls one image route and one email route through the same base URL and Bearer key. Request bodies come from validated JSON rather than guessed fields: EMAIL_REQUEST_JSON contains the token __IMAGE_RESULT__ where the image result belongs. This keeps the sample runnable without pretending that every provider uses the same attachment or HTML schema.

It retries 429 responses, honors Retry-After, uses an idempotency key for both writes, and surfaces non-success bodies. No SDK is installed. The plain REST API lets any TypeScript runtime with fetch call both capabilities. Its public discovery surface also returns request and response schemas without a key, so the boundary can be validated before adding credentials or glue code.

Infrai puts 295 routes across 20 modules under one API key and one bill. In this workflow, that means a single credential crosses the image-to-email handoff instead of two secrets, two account setups, and reconciliation between two vendors. It reduces configuration, but it also concentrates the dependency.

import { randomUUID } from "node:crypto";

const baseUrl = required("API_BASE_URL").replace(/\/$/, "");
const apiKey = required("INFRAI_API_KEY");

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

async function post(path: string, body: unknown, operationId: string): Promise<unknown> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${baseUrl}${path}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": operationId,
      },
      body: JSON.stringify(body),
    });

    if (response.status !== 429) {
      const payload: unknown = await response.json();
      if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(payload)}`);
      return payload;
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  throw new Error("Rate limit persisted after five attempts");
}

function injectImage(value: unknown, image: unknown): unknown {
  if (value === "__IMAGE_RESULT__") return image;
  if (Array.isArray(value)) return value.map((item) => injectImage(item, image));
  if (value && typeof value === "object") {
    return Object.fromEntries(
      Object.entries(value).map(([key, item]) => [key, injectImage(item, image)]),
    );
  }
  return value;
}

const runId = randomUUID();
const imageRequest: unknown = JSON.parse(required("IMAGE_REQUEST_JSON"));
const emailTemplate: unknown = JSON.parse(required("EMAIL_REQUEST_JSON"));
const convertedImage = await post("/image/convert", imageRequest, `${runId}:image`);
const emailRequest = injectImage(emailTemplate, convertedImage);
await post("/email/send", emailRequest, `${runId}:email`);
Enter fullscreen mode Exit fullscreen mode

The configuration cost is three environment variables and two provider-specific request documents. That is still configuration, but it is visible. Discovery exposes request JSON Schema and runnable examples, so those documents can be generated or checked rather than copied from prose.

Compare the alternative honestly. Puppeteer plus Resend requires two signups and two credential sets, along with browser lifecycle code, media extraction, attachment assembly, and cleanup. Puppeteer plus Amazon SES has the same two-account boundary and asks you to write equivalent glue around SES. If Amazon S3 is added for hosted assets, that becomes a third service boundary unless it already belongs to the same AWS account and credential policy. Resend offers a developer-focused email API; SES sits naturally in an AWS-heavy estate. Neither is an image moderation or transformation service by itself.

One combined provider also means one vendor to trust, one bill, and one outage surface. Concentration is a trade-off, not a free simplification.

4. When should the runner-up win?

Choose embedded attachments when remote retrieval is unacceptable and the image must travel with the message. A tightly controlled recipient fleet with known client behavior can make that decision reasonable. Keep the derivative small, test the encoded message rather than the source file, and retain the complete status in text.

Choose hosted images when the same moderated asset appears across the portal and email, when message size matters, or when open measurement is required. Cloudinary, Imgix, or ImageKit deserves a close look if image delivery is the core product surface. S3 plus SES is credible when AWS operations, identity, and object lifecycle are already standard internal machinery. Resend is attractive when email DX matters most and another system already owns image processing. The single-REST-API option earns its place when reducing SDKs, credentials, and handoff glue matters more than best-of-breed separation.

My decision rule is short: hosted by default, attachment by demonstrated offline need. Then test three cases before launch: remote images blocked, the largest allowed derivative, and the text-only version. If any one of those makes the shipment state ambiguous, the template is not ready.

Sources

References used for the comparison and implementation boundaries:

Top comments (0)