Images and object storage

Last updated: Mar 29, 2026Section: General

Images and object storage

Full API reference: https://api.mailoo.app/docs/v1

Mailoo stores user-owned images in S3-compatible object storage (AWS S3, MinIO, etc.). All access goes through the Mailoo API; integrators and browsers never receive S3 credentials or internal object keys.

This page is for teams embedding Mailoo integrations (blog, forms, chat, etc.) who need to understand how article images and other user assets are stored and how to read them safely.

Overview

  • Namespace: Each dashboard user gets a dedicated key prefix users/{userId}/ in a shared bucket (userId is the internal Mailoo user id from the database, a CUID).
  • Public identifier: Clients use the media id (CUID) returned by the API. S3 keys are internal only.
  • Initialization: The API creates an internal marker object under that prefix on first authenticated API activity (idempotent). Creation is logged server-side.
  • Deletion: When a user account is removed via the API, objects under users/{userId}/ are deleted from storage as part of that flow. Project / integration / article deletes enqueue background S3 cleanup for associated media keys.
  • Upload / delete / derive: Bearer-authenticated dashboard flows (typically via apps/web BFF with the user session token). Do not expose S3 keys or bucket credentials to browsers or third-party sites.
  • Originals vs article assets: Upload allows larger originals (up to 25 MiB before processing); POST /images/derive produces resized / re-encoded copies linked to the same scope as the source.
  • Validation: File type is verified with magic bytes; EXIF orientation is applied and metadata is stripped server-side. Animated GIFs are rejected.
  • Canonical embed URLs: Saved markdown / HTML use Mailoo HTTP URLs with ``?id={mediaId}`` on /api/v1/images/public/content. Browsers cannot call that URL with an API key; integrators must proxy (see below).
  • External read (API key): GET /api/v1/images/public/content?id=
 with X-API-Key and scope image.external-read (or a FULL key) returns bytes only for READY media owned by the API key user.
  • ``S3_PUBLIC_BASE_URL``: Optional internal API detail only; it is not exposed in JSON responses and must not be used as the public image contract.

Authentication summary

See ../../api/authentication{.interpreted-text role="doc"} for Bearer tokens and API keys.


Operation Auth


List images (cursor) Bearer; optional projectUid / integrationId / articleId for visibility context

Upload image (multipart) Bearer; form fields file, scope, optional context ids

Derive image (resize / format / compress) Bearer; JSON sourceId and at least one of maxWidth, maxHeight, format

Get image bytes (owner) Bearer; GET /images/{id}/content

Patch / delete by id Bearer

Get image bytes (integrator / server) X-API-Key with image.external-read (or FULL); id must refer to READY media owned by the key user


Security model (normative)

  1. Owner is the authenticated Mailoo user id from JWT or API key --- never a client-supplied user id.
  2. ``projectUid`` / ``integrationId`` / ``articleId`` are context inside that owner's data; the API resolves and checks the chain.
  3. S3 keys are never returned to clients.
  4. RESTRICTED API keys must include image.external-read to call the public read endpoint.

Scopes (visibility)

Media is stored with one of USER, PROJECT, INTEGRATION, or ARTICLE scope. Listing with context returns READY items visible for that editor context (see product plan / IMAGE_API.md).

API reference (Images)

Base path: {apiBase}/api/v1/images.

List (Bearer, cursor)

GET /api/v1/images --- query: limit (1--100, default 24), cursor, optional filters projectUid, integrationId, articleId, scope, includeNonReady=true. Response: items, nextCursor, imagePublicEmbedBaseUrl.

Upload (Bearer)

POST /api/v1/images --- multipart: file, scope (USER | PROJECT | INTEGRATION | ARTICLE), plus context fields per scope (projectUid, integrationId, articleId as required). Response includes id, embedUrl, imagePublicEmbedBaseUrl, metadata --- no S3 key.

Derive image (Bearer)

POST /api/v1/images/derive --- JSON: sourceId, optional maxWidth, maxHeight, format, quality. At least one of maxWidth, maxHeight, or format is required.

Get content (Bearer)

GET /api/v1/images/{id}/content --- raw bytes for READY media.

Patch / delete (Bearer)

  • PATCH /api/v1/images/{id} --- JSON name, altText.
  • DELETE /api/v1/images/{id} --- single-media delete with S3 retry outbox on failure.

Get content (API key)

GET /api/v1/images/public/content?id={mediaId} --- X-API-Key; for server-side proxies only.

Integrator proxy and blog HTML

Blog API article payloads include ``imagePublicEmbedBaseUrl`` (prefix ending with ?id=). Replace canonical Mailoo URLs in htmlContent / markdown with your site's proxy URL using the same media id.

Use ``rewriteMailooPublicImageUrls`` from @mailoo/shared (see repository packages/shared).

Operator configuration (API only)

Set on the API host (not the browser):


Variable Purpose


API_PUBLIC_URL Public API base (no trailing slash); used for canonical embed URLs

S3_BUCKET Bucket name (required)

S3_REGION Region (default us-east-1 if unset)

S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY Credentials (required)

S3_ENDPOINT Custom endpoint for MinIO / S3-compatible (optional)

S3_FORCE_PATH_STYLE true for many MinIO setups (optional; defaults to path-style when S3_ENDPOINT is set)

S3_PUBLIC_BASE_URL Optional; internal to the API only --- not part of the public integration contract


See ../../development/environment{.interpreted-text role="doc"} for where to place variables in development.

Further reading

  • ../../api/authentication{.interpreted-text role="doc"}
  • ../../api/overview{.interpreted-text role="doc"}
  • website-forms{.interpreted-text role="doc"}
  • blog-headless-cms{.interpreted-text role="doc"}
  • Root IMAGE_API.md in the repository
📚