Delivering HTML Emails with Mailgun-Go

Written by Derrick Wippler

Categories: For Devs

5 minute read time

In this tutorial, I will demonstrate how you can send HTML emails with embedded images with mailgun-go. Before we dive into the code, lets first define the problem space and how we can use Mailgun to enhance the user experience of our application.

Introducing Channel-stats

Channel-stats is a Slack bot for collecting statistics on messages sent to a Slack channel. In addition to collecting counts of emoji’s and links shared in the channel, it also performs sentiment analysis of the messages and provides a positive or negative score for the messages which can later be reviewed by users and graphed as a percentage of total messages.

We want to expand on this capability with a weekly email report on the statistics of a channel to our users. Since our email will include graphed data, plain text emails would be pretty boring, instead, we want to send rich HTML email with graphs to our user’s inbox. To accomplish this we need to craft some HTML, with inline CSS and images to make it visually appealing.

HTML Email

A lot has been written on the subject of sending HTML in emails but here are a few good rules to follow:

  • DO use inline CSS
  • DO use HTML TABLES for layout
  • DO use images (prefer .png)
  • DO inline images
  • DON’T use HTML5
  • DON’T use animation CSS
  • DON’T link to an external stylesheet
  • DON’T use CSS styles in the HEAD
  • DON’T use javascript
  • DON’T use flash

It’s often not enough to follow the above rules as there are no established standards on how HTML in emails is rendered. If you are committed to having your emails rendered correctly on as many clients as possible you might consider using a service like Litmus to build, preview, and test your email across a variety of clients. However, for our purpose and since channel-stats is an open source project, I’m keeping production costs low and using some free templates provided by Mailgun (I did get some help from one our UX/UI designers). The result looks like the following:

Now that we have our HTML and CSS, we need to inline the CSS so the majority of email clients will render our email properly. There are a variety of online tools to accomplish this, however we recommend Dialect Premailer for this purpose.

The Code

Since we want the email to be sent on a weekly basis we use a cron library to create a function that will run every Sunday night at midnight. Next, we need to generate the images that will go into our email. Channel-stats already uses go-chart to render .png chart images for the UI, so we can just adapt that for our purposes.

Additionally, when shipping our final project we don’t want to distribute HTML and CSS files separately from our final compiled golang binary, so channel-stats uses the go-bindata project to bundle HTML and CSS into a single channel-stats binary.

Now let’s take a look at the render code.

func NewReporter(conf Config, list ChanLister, notify Mailer, store Storer) (Reporter, error) {
  r := Report{
     log:   GetLogger().WithField("prefix", "reporter"),
     cron:  cron.New(),
     mail:  notify,
     store: store,
     conf:  conf,
     list:  list,
}
  return &r, r.start()
}

func (r *Report) start() error {
  err := r.cron.AddFunc(r.conf.Report.Schedule, func() {
     timeRange := toTimeRange(r.conf.Report.ReportDuration.Duration)
     r.log.Debugf("Creating report for %s to %s", timeRange.Start, timeRange.End)

     for _, channel := range r.list.Channels() {
        // Skip channels the bot is not in
        if !channel.IsMember {
           continue
        }

        html, err := r.genHtml("html/templates/email.tmpl", channel.Name)
        if err != nil {
           r.log.Errorf("during email generate: %s", err)
           return
        }

        data := ReportData{
           Images: make(map[string][]byte),
           Html:   html,
        }

        // Generate the images for the report
        data.Images["most-active.png"] = r.genImage(RenderSum, timeRange, channel.Id, "messages")
        data.Images["top-links.png"] = r.genImage(RenderSum, timeRange, channel.Id, "link")
        data.Images["top-emoji.png"] = r.genImage(RenderSum, timeRange, channel.Id, "emoji")
        data.Images["most-negative.png"] = r.genImage(RenderPercentage, timeRange, channel.Id, "negative")
        data.Images["most-positive.png"] = r.genImage(RenderPercentage, timeRange, channel.Id, "positive")

        // Email the report
        if err := r.mail.Report(channel.Name, data); err != nil {
           r.log.Errorf("while sending report: %s", err)
        }
     }
  })
  if err != nil {
     return err
  }

  r.cron.Start()
  return nil
}

In the Start() method we iterate through all the channels the bot is a member of and generate a report for each channel, We then make a call to genHtml() which retrieves our HTML email as a template called ’templates/email.tmpl’ from our compiled asset store in the HTML package. We then run the template through golang standard HTML/template engine to produce the final HTML. Next genImage() calls the render function with the range of hours and the type of counter we want to retrieve from the data store. Once ReportData is complete, we pass the data to mail.Report() for delivery.

Now that we have our images and HTML, let’s pause and talk a little about HTML MIME and image encoding. MIME is the format which email bodies are encoded to when sent via the SMTP protocol. It is the format that allows email clients to encode HTML, attach and retrieve files and images in an email.

In order for our images to display properly in HTML, we have to encode the images into the MIME. For this we have 2 options: we could add the images as an attachment, or we could inline the images. The RFC on Content disposition says that ‘inline’ indicates the entity should be immediately displayed to the user, whereas ‘attachment’ means that the user should take additional action to view the entity. Since our images are to be displayed immediately to the user via HTML — we choose inline.

At this point, we could use any number of MIME libraries for golang to inline our images and generate the body of the email in MIME format, but with Mailgun, we don’t have to. Mailgun will generate the MIME for us and provides options to inline files and images via the public API. [https://documentation.mailgun.com/en/latest/user_manual.html#sending-inline-images]

Now that we know how to inline images into the MIME, we have to reference them from our HTML. To do this, we use the cid: prefix in our <img> tags. Such that if our inlined image is called most-active.png our image tag would be <img src="cid:most-active.png">


With our HTML ready, let’s look at how we send the email and images with mailgun-go.

func NewMailgunNotifier(conf Config) (Mailer, error) {
  return &Mailgun{
    mg:   mailgun.NewMailgun(conf.Mailgun.Domain, conf.Mailgun.APIKey),
    log:  GetLogger().WithField("prefix", "mailer"),
    conf: conf,
  }, nil
}

// Send a report to the designated email address (could be mailing list)
func (m *Mailgun) Report(channelName string, data ReportData) error {
  if m.conf.Mailgun.ReportAddr == "" {
    m.log.Errorf("mailgun.enabled = true; however mailgun.report-address is empty; skipping..")
    return nil
  }

  // Create a subject for the report
  subject := fmt.Sprintf("[channel-stats] Report for %s", channelName)
  // Create a message with no text body
  message := m.mg.NewMessage(m.conf.Mailgun.From, subject, "", m.conf.Mailgun.ReportAddr)
  // Send the HTML to mailgun for MIME encoding
  message.SetHtml(string(data.Html))

  for file, contents := range data.Images {
    message.AddReaderInline(file, ioutil.NopCloser(bytes.NewBuffer(contents)))
  }

  ctx, cancel := context.WithTimeout(context.Background(), m.conf.Mailgun.Timeout.Duration)
  defer cancel()

  _, id, err := m.mg.Send(ctx, message)
  if err != nil {
    return err
  }
  m.log.Infof("Sent report via mailgun (%s)", id)
  return nil
}

First, we create a new instance of Mailgun using our domain name and API key in NewMailgunNotifier(). Next in the Report() method we call NewMessage() to craft an object to which we will add our HTML and images. Notice the text argument to NewMessage() is empty string. While it is possible to encode both plain text and HTML into the MIME message, we only provide HTML here because inline chart images would be useless to a text-only client. Next, we call SetHtml() and append our inline images via a read closer object which we create on the fly from our []byte buffer. Finally, we ship our crafted request to the mailgun API for MIME construction and delivery using the Send() method.

Conclusion

Hopefully, this tutorial has given some insight on how to deliver high-quality HTML based emails using mailgun-go and the Mailgun API. If you have feedback or find bugs in either project the complete code and library can be found below.

Interested in working at Mailgun? We’re hiring! And there are several dev positions available. Check out our current openings here.


Upcoming Webinar – Predictions & Resolutions: Sending in 2019

In case you didn’t know, we have a webinar coming up next week that we’d love for you to join. Hang out with Nick and Natalie as they talk about some things that happened in email in 2018, and what they think is ahead for 2019. Technical and marketing meeting in the middle to talk shop on January 30th at 1 PM CT, so register now! 

Modified on: March 13, 2019

Stay up-to-date with our blog & new email resources

We'll let you know when we add new email resources and blog posts. We promise not to spam you.