---
title: "Image uploads, MailKaro Docs"
description: "Send the files users upload in the editor to your own storage instead of base64 data: URLs, which bloat the email and are stripped by many inboxes."
---

# Image uploads

Content & assets

# Image uploads

By default, a file the user uploads in the editor is embedded as a base64 `data:` URL, fine for a quick try, but it bloats the email and is stripped by Outlook and Gmail. Provide an `onImageUpload` handler to store the file on your own CDN and return a hosted URL instead. This is MailKaro's equivalent of Unlayer's `registerCallback('image', …)`.

ℹ No entitlement required, it's an embed-time integration, not a paid runtime feature. Without a handler, uploads still work via the `data:` URL fallback.

## Config handler

The handler receives the picked file and returns a hosted URL; both upload sites (the block inspector and the asset library) use it.

tsx editor.tsx

```
<EmailEditor
 config={{
 assets: {
 onImageUpload: async (file, { kind, onProgress }) => {
 const url = await uploadToMyCdn(file, onProgress); // your storage
 return { url, alt: file.name };
 }
 }
 }}
/>
```

## Drop-in (react-email-craft)

The Unlayer-style drop-in accepts the same handler as a prop, or imperatively via `registerCallback` for copy-paste migration:

tsx dropin.tsx

```
// As a prop:
<EmailEditor onImageUpload={async (file) => ({ url: await upload(file) })} />

// Or Unlayer-style, imperatively:
editorRef.current.editor.registerCallback("image", (file, done) => {
 upload(file)
 .then((url) => done({ url }))
 .catch((err) => done({ error: err.message })); // shown to the user in the editor
});
```

✦ Return `{ url, alt? }`. If you omit `alt`, MailKaro derives it from the filename. Throw (or reject) to signal failure, the editor shows an error and keeps the previous image. See [Image libraries](https://mailkaro.com/docs/image-libraries) for read-only browse sources.

## Your own image library

Have a media library already? Point "Open Library" on image and GIF blocks at it, like Unlayer's `registerCallback('selectImage', …)`. Open your picker, then call `done` with the chosen image. Call it with nothing (or never) when the person closes your library without choosing: the image stays as it was.

tsx library.tsx

```
editorRef.current.editor.registerCallback("selectImage", (data, done) => {
 openMyMediaLibrary({ kind: data.kind }).then((picked) => {
 done(picked ? { url: picked.url, alt: picked.alt } : {});
 });
});

// Or, with the config-based editor:
<EmailEditor config={{ assets: { onSelectImage: async ({ kind }) => openMyMediaLibrary({ kind }) } }} />
```

## Videos

The Video block accepts a video file (MP4, WebM or MOV). The editor uploads it through the same handler, takes its first frame as the thumbnail and uploads that too, so the block plays in the canvas and in Preview. A video needs the handler: without one the editor refuses the upload, because a base64 video would not play in an email and would be huge.

## Upload progress

While a file goes up, the block's settings show a live progress bar: the file's name, the percent sent and how many MB of the total, ending when the upload does. The bar follows what your handler reports, so call `onProgress(percent)` (0 to 100) from it. A handler that never reports shows a moving bar instead. The same bar shows in the asset library and for carousel slides; a video upload counts its thumbnail as the last tenth.

`fetch` cannot say how much of an upload has been sent, so `mailkaro-core` ships `uploadWithProgress`, which does it with real byte counts, either to your backend or straight to S3 with a presigned URL:

ts editor.tsx

```
import { uploadWithProgress } from "mailkaro-core";

// to your backend (multipart field "file", reply { file: { url } } or { url })
onImageUpload: (file, { onProgress }) =>
 uploadWithProgress("/api/upload", file, { onProgress, withCredentials: true })

// straight to S3 with a presigned PUT URL
onImageUpload: async (file, { onProgress }) => {
 const { uploadUrl, publicUrl } = await getPresignedUrl(file.name, file.type);
 return uploadWithProgress(uploadUrl, file, {
 body: "raw", headers: { "Content-Type": file.type }, resultUrl: publicUrl, onProgress
 });
}
```

Options: `body` (`"form"` or `"raw"`), `method`, `fieldName`, `headers`, `withCredentials`, `signal` (an `AbortSignal` to cancel), `resultUrl` and `parse` for another reply shape. A failed upload rejects with the server's own message, else a plain one ("The upload failed. Check your connection and try again.").

## Cancelling an upload

A small cross sits beside the progress bar. Pressing it asks *"Are you sure you want to cancel this upload?"* with **Yes, cancel** and **No, keep uploading**. While the question is open the upload is on hold: the bar stands still, nothing is applied to the block, and a request that has not been sent yet waits. **No** carries on. **Yes** aborts the upload, removes the bar and says "Upload cancelled." (it is not shown as an error); the block keeps what it had.

Your handler gets what it needs to follow along: `signal` (an `AbortSignal` that fires when the user confirms) and `whenResumed()` (resolves at once, or when the user answers). `uploadWithProgress` honors both, so pass them through:

ts editor.tsx

```
onImageUpload: (file, { onProgress, signal, whenResumed }) =>
 uploadWithProgress("/api/upload", file, { onProgress, signal, whenResumed })
```

ℹ A browser cannot pause bytes that are already on the wire, so a cancel question that opens mid-upload freezes the bar and holds the result, but the transfer keeps going underneath until the user answers. Yes stops it. A handler that ignores `signal` is still released at once: the editor stops waiting for it and drops its late result. For a true pause, upload in chunks (S3 multipart) and wait on `whenResumed()` between parts.

## The uploaded link and replacing it

Once an Image, GIF, Video or File block holds an uploaded asset, its settings show the hosted link (for example your S3 address) in a read-only box with a `Copy` button. Choosing another file, from the upload button, the Replace button or by dropping one, asks `Are you sure you want to replace the existing image with a new one?` first. `No, keep it` (or Escape) closes the question and changes nothing, and your handler is not called. `Yes, replace` uploads the new file and puts its link in. A block's own starting sample (a new image block's stock photo, a new video block's example link) is not an upload, so it shows no link and asks nothing.

## Files

The [File block](https://mailkaro.com/docs/block-file) takes any document through the same handler (called with `kind: "file"`). On your backend use `admin.uploadFile` instead of `uploadAsset`: it accepts documents (PDF, text, CSV, XML, HTML, Word, Excel, ZIP) up to 20 MB and checks them by their bytes.

## Email size limit

Set `limits.maxEmailBytes` to cap the whole email (its HTML plus every image and video it links). The toolbar then shows a live size, and an upload or library pick that would pass the limit is refused. The editor only knows the size of files it uploaded or listed (return `size` in bytes from your S3 list endpoint), so also check the limit on your server when you save.

tsx editor.tsx

```
<EmailEditor config={{ limits: { maxEmailBytes: 24 * 1024 * 1024 } }} />
```

Source: https://docs.mailkaro.com/image-uploads
