Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Render React Component (JSXConsructor) to Email Template #428

Open
AlbinoGeek opened this issue Nov 16, 2024 · 2 comments
Open

Render React Component (JSXConsructor) to Email Template #428

AlbinoGeek opened this issue Nov 16, 2024 · 2 comments

Comments

@AlbinoGeek
Copy link

Hello!

What is the expected/supported way to convert a React component to an email template to use with mailgun? It would appear competing solutions are much better suited for this, notably with Resend.js having the following:

{
  React: <MyComponent />
}

What I've tried so far (with various levels of failure):

ReactDOM.render(<Component />) // missing images, CSS breaks
ReactDOMServer.renderToString(<Component />) // missing images
ReactDOMServer.renderToStaticMarkup(<Component />) // missing images

Likewise, even if you manually load the images, string replace them in the rendered template with equiv; cid: references, many image types (most notably SVG) do not work whatsoever, displaying no src on the receiving side.

I'll repeat, with Resendjs, this "just works" including SVG images.

@olexandr-mazepa
Copy link
Collaborator

Hello @AlbinoGeek

What is the expected/supported way to convert a React component to an email template with mailgun?

There are a few options available:

  • You can use @react-email/render or any other library to convert the component into an HTML representation. This is what the mentioned Resend.js does under the hood.
    It should be something like this:
import { render } from "@react-email/render";
....
const html = await render(<YourComponent />, {
   pretty: true,
 });
 
 const baseMessageBody = {
   from: "from_value",
   to: "to_value",
   subject: "subject",
   html: html
 };
 res = await mg.messages.create(YOUR_DOMAIN, baseMessageBody);
  • You can also send images as inline images using the cid instruction with a manually created HTML template
const handleSVGSend = async () => {
    const element = document.getElementById('png-logo'); // <img id='png-logo'>
    if(element) {
      const img: HTMLImageElement  = element as HTMLImageElement;
      const res = await fetch(img.src);
      const blob = await res.blob();
      const messageObj = {
        from: "from_value",
        to: "to_value",
        subject: "subject",
        html: '<div><img alt="image" id="1" src="cid:test.png"/></div>',
        inline: {
          filename: 'test.png',
          data: blob
        }
      }
      res = await mg.messages.create(YOUR_DOMAIN, baseMessageBody);
    }
  }

One more thing is that sending the SVG images via email may not be the simplest thing to do due to a bunch of email client limitations, so consider converting them to PNG or another format.

@AlbinoGeek
Copy link
Author

I found that using @react-email/render imported significantly more packages (including a whole-ass headless browser stack, puppet, etc.) to my project than using Resend.js did, which is what led me to think it wasn't as simple as them using that package. Further, the result did not convert SVGs correctly, where using their package did. (I wonder what the difference is? Surely the pretty parameter isn't the only difference?)

I ended up "fixing" this with a convoluted code blob using sharp but it's far from elegant.

import Facebook from '@mui/icons-material/Facebook'
import Instagram from '@mui/icons-material/Instagram'
import LinkedIn from '@mui/icons-material/LinkedIn'
import { createReadStream } from 'fs'
import { renderToString } from 'react-dom/server'
import sharp from 'sharp'

const convertSvgToImage = async (
  MuiSvgIcon: typeof Facebook
): Promise<Buffer> => {
  const html = renderToString(<MuiSvgIcon />)
  const css = RegExp(/MuiSvgIcon-root{(.*)}/).exec(html)?.[1] ?? ''

  return await sharp(Buffer.from(html
    .replace(/<style data-emotion[^>]*>.*<\/style>/, '')
    .replace(/class="[^"]*"/, `style="${css}"`)
  )).resize(48, 48).png().toBuffer()
}

export const inline = [
  {
    data:        createReadStream(`${__dirname}/../../../../public/logo.png`),
    filename:    'logo.png',
    contentType: 'image/png',
  },
  {
    data:        await convertSvgToImage(Facebook),
    filename:    'facebook.png',
    contentType: 'image/svg+xml',
  },
  {
    data:        await convertSvgToImage(Instagram),
    filename:    'instagram.png',
    contentType: 'image/svg+xml',
  },
  {
    data:        await convertSvgToImage(LinkedIn),
    filename:    'linkedin.png',
    contentType: 'image/svg+xml',
  },
]
import { inline as defaultInlines } from '@/server/emails/images'
import FormData from 'form-data'
import Mailgun, {
  type FormDataInputValue,
  type MailgunMessageData
} from 'mailgun.js'
import type { ReactNode } from 'react'
import { renderToString } from 'react-dom/server'

const mailgun = new Mailgun(FormData)

type Options = Pick<MailgunMessageData,
  'attachment' | 'bcc' | 'cc' | 'inline' | 'to'> & {
  html?: string
  subject: string
  text?: string
  [key: string]: FormDataInputValue
}

export default async function sendEmail(
  data: Options,
  component?: ReactNode,
): Promise<boolean> {
  if (!process.env.MAILGUN_API_KEY || !process.env.MAILGUN_DOMAIN) {
    console.error('MAILGUN_API_KEY and MAILGUN_DOMAIN must be set')
    return false
  }

  // Render component if provided
  if (component) data.html = renderToString(component)
  if (!data.html && !data.text) {
    throw new Error('You must provide either component, html or text')
  }

  // Append or set inline images
  if (data.inline) {
    data.inline = [
      ...defaultInlines,
      ...data.inline,
    ]
  } else data.inline = defaultInlines

  const mg = mailgun.client({
    username: 'api',
    key:      process.env.MAILGUN_API_KEY
  })

  try {
    const res = await mg.messages.create(process.env.MAILGUN_DOMAIN, {
      ...data as MailgunMessageData,
      // Overrides here (such as `from`)
    })

    if (res.status !== 200) throw new Error(JSON.stringify(res))
  } catch (error) {
    console.error('Failed to send email', error)
    return false
  }

  return true
}

Which resulted in me blindly appending these cid/images to every email, even if they don't include them; which again is far from elegant. The emails load slowly, and some browsers/mail clients show the attachments instead of inlining them as expected. However, the Resendjs emails load faster and include their images as expected, in-place as base64 (seems their server processes the images?)

The emails resendjs sends even end up smaller on the client side (165kb .eml vs 191kb with mailgun.)

Hopefully this message helps people in the future who are stuck with mailgun due to legacy projects.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants