šŸ’Œ Render emails from ActionMailer in HTML views

A walkthrough on how to dig inside rails base code.

January 7, 2020 - 4 minute read -
EN Ruby on Rails

tldr: You can scroll to the Solution section if you want the final code.

Sometimes you need to let your users preview some emails, like transactional emails customized by them with some content or style.

I have to built a preview system like this for Superdocu, and I thought itā€™ll be an easy task thanks to ActionMailer::Preview. But it wasnā€™t : thereā€™s no officiel documentation for embedding this feature in production.

Thereā€™s plenty of topics on StackOverflow, but none of them explains how to extract or use the rails preview system used in development.

Thereā€™s also a few gems (Maily or Rails Email Preview for example), but they use rails engines, which is not really end-user friendly.

Finally, the default preview system used by ActionMailer::Preview is not a valid solution, because it canā€™t be easily integrated ou customized in the application.

I tried to call the mailer directly and render its html part (decoded) like this:

Controller

@preview = UserMailer.signup(user).html_part.decoded

View

<%= @preview.html_safe %>

But it didnā€™t work, because of inline attachments which arenā€™t correctly decoded šŸ˜•

So I have to understand how rails emails previewing system works and how to use it in order to implement my previewing system, and avoid to rewrite in html my mailersā€™ views (it uses the mjml framework, so mjml templates and not html templates).

Investigating how to extract the email previewing system

I started by investigating rails logs to find the name of the controller which handles the preview action : Rails::MailersController

I searched in the rails basecode, with the controller named, which can be find here, in the railties folder.

It has multiple actions, but itā€™s the preview action which is interesting for us : it handles both the entire page and the iframe part.

Here is the preview action code šŸ‘‡

def preview
  if params[:path] == @preview.preview_name
    @page_title = "Mailer Previews for #{@preview.preview_name}"
    render action: "mailer"
  else
    @email_action = File.basename(params[:path])

    if @preview.email_exists?(@email_action)
      @page_title = "Mailer Preview for #{@preview.preview_name}##{@email_action}"
      @email = @preview.call(@email_action, params)

      if params[:part]
        part_type = Mime::Type.lookup(params[:part])

        if part = find_part(part_type)
          response.content_type = part_type
          render plain: part.respond_to?(:decoded) ? part.decoded : part
        else
          raise AbstractController::ActionNotFound, "Email part '#{part_type}' not found in #{@preview.name}##{@email_action}"
        end
      else
        @part = find_preferred_part(request.format, Mime[:html], Mime[:text])
        render action: "email", layout: false, formats: [:html]
      end
    else
      raise AbstractController::ActionNotFound, "Email '#{@email_action}' not found in #{@preview.name}"
    end
  end
end

The first condition handles when thereā€™s no action in the path. For example if you have an actionmail preview for UserMailer, it will renders available preview methods.

The second part (in the first else) handles both the view with iframe and the mailer content part inside iframe ( the content part action is called here ).

Now I have to understand how the final render works on this preview system: thereā€™s a class which handles this magic, which is ActionMailer::InlinePreviewInterceptor.

This class replace converts image tag src attributes that use inline cid: style URLs to data: style URLs so that they are visible when previewing an HTML email in a web browser.

In our preview action, this class is called here:

  @email = @preview.call(@email_action, params)

The call method from ActionMailer::Preview class calls a private method inform_preview_interceptors ( source ), which calls the ActionMailer::InlinePreviewInterceptorā€™s transform method ( source ), which does the trick on images šŸŖ„

Solution

Thanks to this investigation, I managed to fix the original implementation like this:

Controller

email = UserMailer.signup(user)
@preview = ActionMailer::InlinePreviewInterceptor.previewing_email(email).html_part.decoded

View

<%= @preview.html_safe %>

Have a nice day šŸ™ƒ