RkBlog

Sending cross-email-client-compatible emails with MJML and Python

2024-11-12

It's hard to send good-looking emails - what looks good in a browser will usually look bad in an email client, even when it's a web application opened by the same browser. From Gmail to Outlook, there is a varying support of HTML/CSS which makes it hard to cover all email clients with one design.

There are however solutions that help with this problem. React Email works as an abstraction layer generating email-compatible HTML from widgets and simple HTML, but binds you to the React ecosystem. A more universal solution is MJML, which is sort of a standard that then compatible parses turn into email-safe HTML.

There are JavaScript parsers for MJML but a lot of other languages have their own as well, giving you the freedom of choice. Python has it own parses available as well.

Basics of email-safe HTML

The old approach was to write email HTML messages manually while having email client compatibility in mind. This does require a lot of inbox testing and knowledge. A simplified approach would look like so:

There is a lot of limitations and inbox testing of emails is an actual thing and companies sell such services. If you have a marketing email you want people to see it and interact with it. If you have a registration email you want the confirmation button to appear so the user can click it.

Now unmaintained premailer can be used to inline the CSS of an email HTML message. Then you can use HTML cleaners like tidylib to remove non-standard tags, and fix some HTML validation errors - but it can also alter how the message looks. This is even more problematic when you have HTML messages designed in HTML WYSIWYG editors which tend to output website-save and not email-safe code.

And then often you had to add conditional HTML for Outlook to make it look decent, like so:

<!--[if mso]>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="width:600px">
<tr>
<td width="600">
<![endif]-->
YOUR MESSAGE HERE
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->

If you don't use a table then Outlook (and some other email clients) will just display the email full width disregarding styles put on a DIV or similar element.

Gmail is much better but also not perfect. SVG overall is a bad idea, while WebP or AVIF is either unsupported or the Gmail web app can convert them to JPEG replacing the transparency layer with black. Apple email client in the latest versions seems to have the most features supported. caniemail.com is your friend.

MJML

MJML is sort of a standard designed by Mailjet - a company offering email marketing services, including WYSIWYG editors. They had the problems described above and needed a solution. Older wrappers like React Email aren't that friendly to use in online editors or by other programming languages so MJML was born.

MJML is a limited set of HTML/CSS and custom tags that then is compiled into email-safe HTML taking various email clients into account. It has support by WYSIWYGs like GrapesJS on the frontend but also by various libraries on the backend.

You can try it online on https://mjml.io/try-it-live/. This is the example MJML code:

<mjml>
  <mj-body>
    <mj-section>
      <mj-column>
        <mj-image width="100px" src="/assets/img/logo-small.png"></mj-image>
        <mj-divider border-color="#F45E43"></mj-divider>
        <mj-text font-size="20px" color="#F45E43" font-family="helvetica">Hello World</mj-text>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

And this is the HTML output:

<body style="word-spacing:normal;">
  <div style="">
    <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
    <div style="margin:0px auto;max-width:600px;">
      <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
        <tbody>
          <tr>
            <td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
              <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
              <div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
                <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
                  <tbody>
                    <tr>
                      <td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
                        <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
                          <tbody>
                            <tr>
                              <td style="width:100px;">
                                <img height="auto" src="/assets/img/logo-small.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="100" />
                              </td>
                            </tr>
                          </tbody>
                        </table>
                      </td>
                    </tr>
                    <tr>
                      <td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
                        <p style="border-top:solid 4px #F45E43;font-size:1px;margin:0px auto;width:100%;">
                        </p>
                        <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 4px #F45E43;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]-->
                      </td>
                    </tr>
                    <tr>
                      <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
                        <div style="font-family:helvetica;font-size:20px;line-height:1;text-align:left;color:#F45E43;">Hello World</div>
                      </td>
                    </tr>
                  </tbody>
                </table>
              </div>
              <!--[if mso | IE]></td></tr></table><![endif]-->
            </td>
          </tr>
        </tbody>
      </table>
    </div>
    <!--[if mso | IE]></td></tr></table><![endif]-->
  </div>
</body>

Such HTML is hard to maintain, not to mention write rapidly for new email campaigns, etc.

Python and MJML

Parsing an MJML message/template is easy, pretty much a one-liner:

import mjml

def render_mjml(mjml_message):
    result = mjml.mjml_to_html(mjml_template)
    if result.errors:
        raise ValueError(result.errors)
    return result.html

If you get the result HTML code you can send the email message through your usual methods... which can also become a problem - on compatibility or reliability.

Sending emails with media

You can send emails with images linked from your website, however depending on the email client by default loading external assets can be disabled causing the email to look bad if it's image-heavy. This is also one of the reasons why some companies add open in browser links to their email messages (linking to the standard HTML version of the message on their website).

<mj-image width="300px" src="https://www.python.org/static/img/python-logo.png"></mj-image>

There is an option to attach images as email attachments but use them in the content of the email rather than displaying them as attachments - this is done through Content-ID (CID). In Python you have to attach an image using email.mime.image.MIMEImage instead of email.mime.application.MIMEApplication.

If you tag an attached image with CID logo then in the message you can use it in the SRC attribute like so (HTML or MJML):

<mj-image width="300px" src="cid:logo"></mj-image>

Here is a full Python SMTP example sending an email with CID image:

import smtplib

from email.mime.text import MIMEText
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart

import mjml


TEMPLATE = '''
<mjml>
  <mj-body>
    <mj-section background-color="#ffde57">
      <mj-column>
        <mj-image width="300px" src="cid:logo"></mj-image>
        <mj-divider border-color="#646464"></mj-divider>
        <mj-text font-size="20px" color="#4584b6" font-family="helvetica">This is a test MJML email!</mj-text>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>
'''


class Configuration:
    port = 587  # this will vary between different configurations for SMTP servers
    smtp_server = "YOUR_HOST"
    login = "YOUR_LOGIN"
    password = "YOUR_PASSWORD"
    sender_email = "EMAIL"
    receiver_email = "RECIPIENT_EMAIL"
    subject = "Email Test!"


def run():
    result = mjml.mjml_to_html(TEMPLATE)
    if result.errors:
        raise ValueError(result.errors)
    html = result.html
    message = build_message(html)
    send(message)


def build_message(html):
    message = MIMEMultipart("related")
    message["Subject"] = Configuration.subject
    message["From"] = Configuration.sender_email
    message["To"] = Configuration.receiver_email
    part = MIMEText(html, "html")
    message.attach(part)

    message = attach_content_id_images(message)
    return message


def attach_content_id_images(message):
    images = ['logo.jpg']  # assuming logo.jpg in local folder
    for attachment in images:
        print('- Handling image', attachment)
        parts = attachment.split('.')
        subtype = parts[-1]
        content_id = parts[0]
        fp = open(attachment, 'rb')
        image = MIMEImage(fp.read(), _subtype=subtype)
        fp.close()

        image.add_header('Content-ID', f"<{content_id}>")
        message.attach(image)
    return message


def send(message):
    with smtplib.SMTP(Configuration.smtp_server, Configuration.port) as server:
        server.starttls()  # not every SMTP will use this.
        server.login(Configuration.login, Configuration.password)
        server.sendmail(
            Configuration.sender_email, Configuration.receiver_email, message.as_string()
        )
    print('Sent')


run()

Note that Amazon SES boto library can also take the MIMEMultipart message and send it, so you aren't limited only to SMTP with this.

How to send an email that is not marked as spam?

Your usual email-sending tutorial will use SMTP. This works for one email message but when you have to send a lot of them then you may hit server limits, be marked as spam or just take a long time to send each message. You also have limited knowledge of what happens with sent messages - do they land in spam? Tracking email bounces is another topic.

For Gmail there is an API to use it as a service to send emails - either directly or through OAuth integration. This can be more reliable to send emails but maybe not for a large volume.

For volume email sending on your own I would recommend Amazon SES, where you can track domain reputation, recipient bounces and more.

And things don't end here. Your email domain or server from which email is sent can matter a lot - it can be blacklisted for spam or cause identity problems (phishing attacks) due to lack of DMARC DNS record. You can check some online validators like emaillistverify.com/dns-health-checker or dmarcian.com/domain-checker/.

Domain configuration and health can be monitored online
Checking for blacklists and valid DMARC headers on an email domain

Other tips and tricks

When sending automated emails you likely don't want to receive Out of office reply emails. You can suppress those replies by setting X-Auto-Response-Suppress to OOF on your email message.

Amazon SES has an event queue that can return updates on sent email messages - you can track soft and hard bounces and then exclude hard bounced emails (for example mailbox doesn't exist) and constant soft bouncing emails (mailbox full and abandoned) from you mailing lists - the less bounces the better reputation of your email domain.

If you want to validate if an email address exists, and it's important to collect/validate only such addresses then you can use services like Zerobounce.

Comment article