Image uploads
Content & assetsImage 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', …).
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.
<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:
// 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
});{ 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 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.
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:
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:
onImageUpload: (file, { onProgress, signal, whenResumed }) =>
uploadWithProgress("/api/upload", file, { onProgress, signal, whenResumed })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 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.
<EmailEditor config={{ limits: { maxEmailBytes: 24 * 1024 * 1024 } }} />