crabmusket's avatar

Post-processing email views

I'm trying to integrate MJML with Laravel so we can write super nice email templates. According to this issue, the desired approach for integrating MJML with other template engines is to run the templating engine first, then run MJML on the output once substitutions have been made.

This is fine - I end up with an email template like this, email.blade.php:

<mjml>
  <mj-body>
    <mj-container background-color="#F9F9F9" width="640px">
      <mj-wrapper>
        @foreach($sections as $section_name)
        <mj-section padding-bottom="0" padding-top="0">
          <mj-column>
            {{ $section_name }} {{-- for example --}}
          </mj-column>
        </mj-section>
        @endforeach
      </mj-wrapper>
    </mj-container>
  </mj-body>
</mjml>

I've added a custom render() function on my Mailable class that does something like this, running MJML on the command line:

public function render()
{
    $result = parent::render(); // this renders the blade file to a string
    $process = new Process("mjml --stdin --stdout");
    $process->setInput($result);
    $process->mustRun();
    return $process->getOutput();
}

This works really well if I return the email in a controller for preview in the browser. However, I just discovered that if I actually send the email, render() doesn't get called. It seems that the Mailer actually controls all rendering of email templates for emails that actually get sent out.

So what I'd like to know is - has anyone else done this sort of post-processing before? Is there a relatively easy way I can hook into the Mailer to add my post-processing step? Or do I need to write my own Mailer with a special case for MJML emails and put it in the service container?

0 likes
10 replies
crabmusket's avatar
crabmusket
OP
Best Answer
Level 1

Ok, after a few more hours of digging I realised that render is not the method I was looking for - instead I needed to use buildView. So I am now overriding that method with something like this:

use Illuminate\Contracts\View\Factory as ViewFactory;
use Illuminate\Support\HtmlString;

class MyEmail {

    ...

    protected function buildView()
    {
        $html = app(ViewFactory::class)->make($this->view, $this->viewData)->render();
        return [
            'html' => new HTMLString($this->htmlToMJML($html)),
        ];
    }

I'm not sure if this is intended or correct, but it works - it's mostly copied in structure from the Markdown email flow. Understanding the sequence of calls to send, buildView, and so on is quite confusing.

ClearanceJobsDev's avatar

@crabmusket do you have any issues with the performance of using a separate process to run mjml? I have some scheduled tasks where I send thousands of emails and I am concerned that re-rendering the MJML for each email may be too slow.

Do you have a full example somewhere that I can look at? What does the htmlToMJML() method look like?

crabmusket's avatar

@ClearanceJobsDev we only send weekly bursts of a couple of hundred at a time but we haven't noticed any issues.

Here' s a gist with a code dump of what I'm using currently: https://gist.github.com/crabmusket/1f595e679c8f18d45a138d97be23d3e3

I haven't tested that it works fully in isolation but it's not a lot of code so you should be okay :)

EDIT: I'm not sure what you'd do if you really need higher performance than running MJML every single time. You'd have to really evaluate your email needs and see if you could actually pre-render MJML to HTML before running Blade. This isn't ideal because MJML will tend to do funny things to raw text like template strings. If all the emails in a batch have a largely similar structure with just a few copy tweaks, then maybe you could run a three-step process:

  1. Render through Blade as normal to generate the structure of the email. This should produce a file containing MJML and no PHP splices, but with some template placeholders (e.g. string #NAME# for the recipient's name)
  2. Run MJML on the result, which should produce some gnarly email-friendly HTML. You should only need to do steps 1 and 2 once per blast.
  3. Run a fast custom replacement engine which adds small bits of customisation per-recipient (e.g. preg_replace('#NAME', $user->name))

Step 3 could even be done by an external service. For example, SparkPost allows you to send an HTML template with {{variables}}, and then per-recipient values to fill in. In your Blade file you can use @{{variables}} and then {{variables}} will appear verbatim in the HTML output to be filled in by SparkPost.

The reason for the first Blade pass is to put all the right MJML elements in place. E.g. if you ever have a row with a variable number of columns, you really need to have the MJML contain the correct number of columns when you run mjml, rather than running mjml and then duplicating its output a variable number of times using a templated for loop. Does that make sense?

jamiefishback's avatar

@crabmusket This is awesome, thanks for sharing what you've learned... one small tweak from the gist, line 37 in MJML.php should be changed from $content to $html... thanks again!

crabmusket's avatar

Hah, good catch. I actually changed both of them to $mjml!

malico's avatar

Hey guys. We made this package for this takes into consideration performance. What we did was, when a blade is compiled before it is cached, we also compile the cache file for mjml. That way if you have 100 users, mjml is compiled once.

Really cuts down cost.

https://github.com/escuelademusica/laravel-mjml

1 like
olivermbs's avatar

@EvanSchleret thanks for this! I just tried it out and it seems to be the solution to my problem. I was previously using Vite to convert MJML to blade, but the problem was that I could not use any components/layouts = lots of code repetition and hard to maintain. Now, I can combine MJML with Blade features, amazing!

Question: are the views cached by Laravel? Or does an MJML->PHP conversion happen every time on the fly?

1 like
EvanSchleret's avatar

@olivermbs Sorry, I didn't get any notifications about your reply. I think you might have already found the answer to your question, but on my side, Laravel doesn’t cache views that are converted. That means every time you need to render a .mjml.blade.php file, the MJML-to-PHP conversion runs again. If it's a behaviour that could bother you, I'm not sure about how I could do it but I'll be happy to read your PR if you need it. Have a nice day :)

Please or to participate in this conversation.