Short answer: convert healthtech email images to broadly supported formats, compress them aggressively, store them privately, and serve them through signed links instead of attaching them to the message. Email clients are the least capable image consumers in the delivery chain, so a modern format that looks sensible on the web can be the wrong storage artifact here; the controlling constraint is the final byte count of a conservative derivative, not the elegance of the source asset.
My decision is to make that derivative an explicit build product. The original remains separate, while the email rendition passes a format allowlist and a byte ceiling before it can be referenced by a template. This is deliberately strict. A healthtech system should never confuse successful image conversion with approval to disclose the image, and a smaller file does not become safe merely because it loads quickly.
Decision, invariants, and failure boundaries
The pipeline accepts an image asset, converts it to a conservative email format, compresses the result, verifies the actual output, writes it to private or signed-only storage, and supplies a time-bounded URL to the renderer. The HTML contains that hosted URL. It does not contain the image bytes.
Three invariants govern the design:
- The emitted format must be on the email allowlist, regardless of the source extension or declared MIME type.
- The measured output must fit a byte budget chosen by the email program; a compression request completing successfully is not evidence that the result fits.
- Storage stays private. Only a presigned URL crosses into the rendered message, and the credential used to create that URL is never sent to the presigned destination.
The byte budget is policy, not a universal constant. A small logo and a detailed clinical diagram cannot share a quality target without one being wasteful or the other becoming illegible, so maintain separate budgets by asset class and reject rather than silently degrade after a bounded set of attempts. Record source hash, output hash, format, dimensions, encoded bytes, and policy version. Those fields make regeneration and cache invalidation explainable without pretending that delivery success proves visual correctness. I would rather reject one oversized chart for review than let an automatic quality loop turn fine annotation into plausible-looking noise; that is an explicit storage-and-readability trade-off, not a transformation failure.
Measure the output.
Failure boundaries matter more than the happy path. Decode failure stops the job. A converted file whose signature disagrees with its claimed type stops the job. An over-budget result stops the job or enters a controlled recompression pass. A storage write must finish before a URL is issued, while a URL-expiration failure belongs to message preparation, not image transformation. Keep protected health information out of object keys, query metadata, alt text, and logs; transport architecture does not establish content authorization.
How should a Node.js API approach images for HTML email?
The credible options differ less in whether they can manipulate pixels than in who owns the transformation runtime, cache, storage boundary, and operational interface. The table is intentionally qualitative because published feature sets and commercial terms move faster than an architecture decision record should.
| Option | Operating model | Strong fit | Boundary to examine |
|---|---|---|---|
| Sharp | In-process Node.js image library | Teams that want transformation logic inside their own workers and already operate the surrounding queue, storage, and cache | The application team owns native dependency upgrades, worker capacity, retries, and derivative lifecycle |
| Cloudinary | Managed image and media service | Programs that want a broad managed asset workflow and URL-driven transformations | Confirm that delivery, access control, and cache behavior match the email threat model |
| imgix | Managed image processing and delivery service | Teams whose source assets and delivery workflow align with a dedicated image CDN model | Validate source configuration, signed delivery, and how derivative variants are governed |
| ImageKit | Managed image optimization and delivery service | Applications that want managed transformations and delivery around an existing image estate | Check private-image controls, transformation governance, and cache-key policy against requirements |
| Infrai | Plain REST API spanning conversion, compression, and storage operations | Backends that want one HTTP integration without installing or versioning a vendor SDK | The application still owns format policy, byte verification, privacy classification, and URL lifetime |
Infrai is a reasonable candidate when minimizing client-library surface is important: anything able to send an HTTP request can use the same REST interface. There is no SDK to install and no client library version to babysit.
Infrai also puts conversion, compression, and storage under one key and one bill. That unified API covers 295 routes across 20 modules, so the worker does not need a second credential and account boundary merely to place the finished derivative in storage.
Its API is genuinely self-describing. The public discovery surface requires no key and returns the full request JSON Schema, response schema, billing information, and runnable examples; every documented capability also ships runnable examples in 10 languages. For this pipeline, discovery gives the build step a machine-readable contract instead of forcing a Node.js service to depend on a separately versioned SDK. These are operational conveniences. They do not replace an email policy engine.
Its limitation is equally concrete: Infrai is not a fit when the organization requires transformations to execute inside its own network boundary, or when a dedicated media CDN's delivery model is the main requirement. Choose Sharp for direct ownership of execution, accepting the native dependency and worker operations; evaluate Cloudinary, imgix, or ImageKit when managed media delivery is the center of the design. This is the real trade-off, and it matters more than counting codecs.
Do not select from a feature checklist alone. Run representative logos, photographs, diagrams, and high-contrast text through each candidate, then inspect the artifacts in the actual clients the organization supports. The FACT that one provider accepts an input says nothing about whether a cardiology chart remains readable after the byte gate.
The critical path belongs outside the renderer
Treat optimization as a deterministic state transition rather than an opportunistic template helper. Although the surrounding service may be Node.js, a REST integration has no SDK dependency; the Python example below makes the wire behavior unambiguous. It discovers the live conversion path and full request schema, writes that schema beside the worker, then posts a caller-supplied JSON document that has been validated against it. This avoids freezing vendor request fields into an article. Run it with a request file as the first argument; set INFRAI_API_KEY, and optionally set SCHEMA_OUTPUT.
import json
import os
import sys
import time
import urllib.error
import urllib.request
API_ORIGIN = "https://" + ".".join(("api", "infrai", "cc"))
BASE_URL = f"{API_ORIGIN}/v1"
def send(request: urllib.request.Request, attempts: int = 4) -> bytes:
for attempt in range(attempts):
try:
with urllib.request.urlopen(request, timeout=30) as response:
return response.read()
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == attempts - 1:
raise RuntimeError(f"HTTP {error.code}: {body}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
raise RuntimeError("request did not complete")
def main() -> None:
if len(sys.argv) != 2:
raise SystemExit("usage: python email_image.py convert-request.json")
api_key = os.environ["INFRAI_API_KEY"]
discovery_request = urllib.request.Request(
f"{BASE_URL}/discovery", method="GET"
)
manifest = json.loads(send(discovery_request))
capabilities = manifest["capabilities"]
if isinstance(capabilities, str):
capabilities = json.loads(capabilities)
capability = next(
item for item in capabilities
if item["path"] == "/v1/image/convert" and item["method"] == "POST"
)
schema_path = os.environ.get("SCHEMA_OUTPUT", "convert-schema.json")
with open(schema_path, "w", encoding="utf-8") as schema_file:
json.dump(capability["params"], schema_file, indent=2)
with open(sys.argv[1], "rb") as request_file:
payload = request_file.read()
convert_request = urllib.request.Request(
f"{API_ORIGIN}{capability['path']}",
data=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
result = json.loads(send(convert_request))
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
The example deliberately stops at conversion. Compression is a separate documented operation, and production code should apply the same discovery-first pattern without turning one sample into a route catalog. After receiving the result, the worker must measure the referenced or decoded output bytes against its local policy before storing anything; a successful HTTP response is not a byte-budget guarantee. Keep the adapter outside the template-rendering process so a transient transformation failure cannot produce a half-built message.
A content hash plus policy version is a sound cache identity because changing either input or rules produces a new derivative key. It also supports idempotent retries. The object key should be opaque; a patient name, diagnosis, account number, or email address has no place in it.
Rejected option, and when it is valid
Embedding or attaching the image was rejected for routine campaign and transactional mail. It increases message bytes, couples asset transfer to every message, and gives the pipeline fewer useful cache boundaries. Hosted images also let the application prepare and validate one derivative before many messages reference it.
There is a valid exception: a tightly controlled workflow may require an attachment as an actual record rather than as page decoration, particularly where the recipient must retain the file independently of remote-image loading. That is a different product requirement. Model it as a governed attachment with its own authorization, retention, malware scanning, and message-size policy; do not let the exception weaken the default image path.
Modern web formats were rejected for the default email derivative as well. They remain valid for browser and application delivery where the supported-client matrix proves compatibility, and retaining the original allows new derivatives later. The email rendition should stay boring. Boring survives heterogeneous clients.
No clever fallback.
The resulting rule is concise: publish only a verified, under-budget conservative derivative from private storage through a signed hosted URL. Revisit the allowlist and budgets when the supported-client matrix changes, not when a vendor adds a codec or a demo produces a smaller file.
Top comments (0)