MJML – responsywny mailing na ASAPie

30 lipca 2019 Anna

Responsywne, skalowalne, interaktywne i kuloodporne… maile? „Chyba w snach.‟ – pomyśleliśmy, ale klient właśnie takie chciał: mailing mobile first jak ze snów. Poznajcie historię, jak w kilka godzin weszliśmy na nowy poziom kodowania maili dzięki sprawnej komunikacji, łutowi szczęścia i świetnemu narzędziu MJML. Dołączamy fragmenty kodu pokazujące implementację w projekcie opartym na Symfony. 

Nie interesuje Cię ta opowieść? Skocz do »Szczegółów implementacji«

Wstęp

Mailing jest dla wielu systemów głównym kanałem komunikacji z klientem; szablony muszą być atrakcyjne wizualnie, responsywne i przyciągające uwagę, a przede wszystkim powinny przekazywać w sposób jednoznaczny informacje na wszystkich urządzeniach, na jakich e-mail może zostać przeczytany. Marketingowcy i UX designerzy tworzą wyszukane projekty graficzne, ale na końcu jest zwykle deweloper, który musi – mówiąc kolokwialnie – „zakodzić‟ templatkę. 

Problem...

Na początku tworzenia systemu maili było niewiele, a my skupiliśmy się na innych, krytycznych tematach. Wraz z jego rozwojem liczba rodzajów wysyłanych wiadomości urosła do kilkudziesięciu templatek. Zmienił się także charakter wysyłanych komunikatów. Odtąd maile miały służyć pozyskiwaniu nowych klientów, a zatem pełnić po prostu funkcję reklamy.

Zanim przyszedł czas na rozbudowy, postanowiliśmy zaudytować nasz mailing w Email on Acid i… wyszło szydło z worka. Zrobiliśmy bezpieczny, skalowalny backend, a na „froncie‟? Okazało się, że pomimo minimalistycznego, bardzo zachowawczego designu, nasze maile wszędzie się rozjeżdżają, na Outlooku zamiast zamierzonej czcionki wyświetla się Times New Roman, obrazki się nie dekodują na Gmailu, a przyciski absolutnie nie nadają się do klikania… Wpadło na boarda zadanie o najwyższym priorytecie, żeby zrobić z tym porządek — naprawić kilkadziesiąt templatek pełnych tabelek i przycisków.

Rozwiązanie

Pierwotnie zamierzaliśmy poprawiać maile liniowo, dla każdego problematycznego klienta pocztowego z osobna, ale szybko okazało się to zbyt czasochłonne. Po paru godzinach oczywistym się stało, że potrzebujemy innego rozwiązania

Rozważaliśmy kilka opcji, w tym skorzystanie ze zwykłego inlinera styli bądź jeszcze większe uproszczenie templatek, ale wszystkie te pomysły były niewystarczające. Wówczas wybór padł na coś innego: MJML. (https://mjml.io/

MJML to narzędzie, które udostępnia język znaczników oraz jego „kompilator‟ napisany w Node.js. Ten przerabia kod na HTML tak, aby e-mail wyświetlał się poprawnie na jak największej liczbie popularnych klientów pocztowych. Objętość wygenerowanego HTMLa jest około dziesięciokrotnie większa niż objętość kodu MJML, a to dlatego, że wszystko staje się zagnieżdżonymi tabelami i dodane są znaczniki dedykowane Outlookom. MJML ma świetną dokumentację i mnóstwo gotowych szablonów, które obrazują, jakie efekty możemy uzyskać. Nie każdy layout da się w tym zrobić (jeśli się nie da, to podejrzewaj, że to nie jest dobry pomysł), za to wstępna konfiguracja pod Symfony w wersjach od 2.8 wzwyż jest dziecinnie prosta — znajdziemy tam gotowy bundle.

MJML daje maksimum responsywności, jakie można z e-maila wycisnąć: layout oparty na kolumnach, możliwość grupowania ich, aby na mobile występowały w różnych konfiguracjach. Znajdziemy w nim także masę komponentów helperów do obrazków, linków, teł, a także opcję dodawania preheadera. Twórcy pozostawili możliwość pisania w czystym HTMLu oraz dodawania własnych styli. Udostępnili opcję inlinowania styli lub zostawienia części w headzie maila. Niestety, nawet MJML nie rozwiązuje wszystkich problemów. Wybierając to rozwiązanie, musieliśmy m.in. poradzić sobie sami z Blue Linkami a obrazki przyciąć pod wymiar (aby wyświetliły się w pożądany sposób na starych Outlookach pod Windows 7). Komponent mj-button okazał się niezbyt udany — choć jak na przycisk w mailu i tak działa lepiej niż nieźle — brakuje mu zaokrąglonych borderów na Outlookach a klikalny jest tylko na napisie. Podczas projektowania maili wciąż lepiej unikać tabel czy obrazków w tłach.

Drugi etap

Maile na MJMLu weszły na produkcję, klient przestał mieć do nich zastrzeżenia, więc nadszedł czas na rozbudowy. Otrzymaliśmy zlecenie przepisania wyglądu 30 szablonów e-maili zgodnie z nowymi wymaganiami graficznymi. Wróciła także potrzeba, aby templatki występowały w różnych brandach w zależności od kontekstu (zmieniać miały się: logo, translacje, kolorystyka). 

Po przeanalizowaniu grafik dostarczonych przez klienta wydzieliliśmy około 20 rodzajów elementów powtarzających się w różnych e-mailach,  które wynieśliśmy do osobnych plików .mjml.twig. Dla bloków różniących się wersjami brandingu zrobiliśmy osobne przestrzenie (m. in. logotyp, przycisk logowania, numery kontaktowe). Nagłówek i stopka, takie same w każdym mailu, już wcześniej były wydzielone, wystarczyło tylko uporządkować kod. 

Ostatecznie, pojedynczy e-mail, który może wystąpić w różnych brandach, wystarczy poskładać z generycznych bloków, a programista, który realizuje nowy szablon, nie widzi nawet kodu MJML (jest on przesłonięty modułami w twigu). Czas kodowania jednego e-maila został ograniczony z 8 do mniej niż 2 godzin, a wszystkie wiadomości są spójne pod względem wizualnym.

code

Szczegóły implementacji

Skorzystaliśmy z gotowego bundla: https://github.com/notFloran/mjml-bundle

Możliwości konfiguracji pod Symfony 2.8

  1. Domyślna
    w tym przypadku na środowisku produkcyjnym będziemy potrzebować Node.js tak skonfigurowany, aby aplikacja w PHP mogła go sama odpalić (ta uwaga dotyczy szczególnie opiekunów środowisk skonteneryzowanych) oraz dociągnięty do node_modules sam mjml;
  2. Rozwiązanie własne:
    implementacja interfejsu Renderera z paczki notFloran/mjml-bundle przyjmuje MJML,  zwraca HTML, do programisty należy decyzja, w jaki sposób to się stanie;
  3. Integracja z oficjalnym API MJML:
    wysyłka po HTTP wszystkich swoich maili do zewnętrznego serwisu, aby to on zajął się przepisaniem kodu na HTML.

Z marszu odrzuciliśmy trzecie rozwiązanie: nie mieliśmy pewności, czy wolno nam wysłać mailing do zewnętrznego serwisu. Drugie musieliśmy zostawić ze względu na czas. Domyślna konfiguracja z własnym plikiem wykonywalnym do konwertowania okazała się wystarczająca. Zrealizowaliśmy ją zgodnie z dokumentacją:
https://github.com/notFloran/mjml-bundle/blob/v1.0.0/README.md

code

Zalety:

  1. 15 minut zajęło nam napisanie działającej konfiguracji e-maili na środowisku lokalnym.
  2. Na środowisku produkcyjnym trzeba było dociągnąć node.js do kontenera z aplikacją, co zajęło kolejne 15 minut.
  3. Nie wysyłamy maili do żadnego zewnętrznego API: w ten sposób nie uzależniamy się od żadnych zewnętrznych systemów.

Uwagi:

  1. Przy każdym renderowaniu e-maila musi zostać odpalony Node.js, a przeprocesowanie takiej wiadomości zajmuje około 400-700ms. Myśleliśmy o napisaniu własnego rozwiązania, polegającego na tym, że templatki będą konwertowane z MJML na HTML podczas buildu wraz z assetami i zostaną w nich tylko miejsca na zmienne z Twiga, ale na razie nie realizowaliśmy tego pomysłu, bo nie było  takiej potrzeby.
  2. Przy konfiguracji, Symfony 2.8 + mjml4, bundle nie minifikuje e-maili ponieważ sposób podawania mjml2html parametrów między wersjami 3 i 4 się zmienił, a wersja 1 bundla formalnie współpracuje tylko z mjml3. Jest to jednak jedyny problem związany z niezgodnością wersji, w łatwy sposób naprawialny: wystarczy podmienić Renderer, który zrobi to samo co ten natywny, tylko w inny sposób obrobi opcje konfiguracyjne.

Części kodu

Serwis do wysyłki maili:

//...
use NotFloran\MjmlBundle\Mjml;
 
//...
 
class SendEmailMessageService
{
    private $mailer;
    private $engine;
    private $mjml;
 
    //    ...
    public function __construct(\Swift_Mailer $mailer, \Twig_Environment $engine, Mjml $mjml /* ... */)
    {
        $this->mailer = $mailer;
        $this->engine = $engine;
        $this->mjml = $mjml;
        //...
    }
 
    public function send(EmailMessageInterface $message): void
    {
        //... wyciągnięcie z $message tematu, odbiorcy, nadawcy, udw i załączników, dispatch eventów o wysyłce maila
 
        $body = $this->getBody($message);
 
        //... zbudowanie obiektu maila, zasetowanie załączników i nagłówków
 
        $this->mailer->send($mail);
    }
 
    private function getBody(EmailMessageInterface $message): string
    {
        return $this->mjml
            ->render($this->engine->load($message->getTemplate())
                ->renderBlock('body', $message->getParameters()));
    }
    //... metody do obsługi załączników i tematu
}

Przykładowa templatka maila:

{# Tu temat maila, konceptualnie jest on częścią widokową maila, więc może znaleźć się w twigu #}
{% block subject %}
    {{ 'Limit has been set'|trans({}, 'mail')|raw }}
{% endblock %}

{# Poniższa część jest renderowana w SendEmailMessageService jako body maila #}
{% block body %}
    {% set layoutPath = '@BackendMailing/email/' ~ layout|default('')|lower ~ 'default' %} {# w tym miejscu sklejamy scieżkę do katalogu z layoutem danej wersji #}
    {% set commonPath = '@BackendMailing/email/common' %}

    {% embed layoutPath ~ '/layout.html.twig' %} {# W tym miejscu osadzamy maila w jego layoucie - tam znajdują się nagłówek i stopka #}
        {% block preheader %}
            {{ 'We are happy with you'|trans({}, 'mail') }}
        {% endblock %}
        {% block content %}
            {% include layoutPath ~ '/block/informal_greetings.html.twig' %} 
      {# Ścieżka bloku brana jest z uwzględnieniem zmiennej layoutPath, bo zwrot grzecznościowy różni się między wersjami #}
            {% include commonPath ~ '/block/spacer.html.twig' with {height: '15px'} %} 
            {# Ścieżka bloku brana jest z przestrzeni common, bo odstęp pionowy wygląda tak samo niezależnie od wersji #}
            {% embed commonPath ~ '/block/text.html.twig' with {fontSize: 'large'} %}
                {% block text %}
                    {{ 'Thank you for submitting your application to our service.'|trans({}, 'mail')  }}
                {% endblock %}
            {% endembed %}
            {% include commonPath ~ '/block/image.html.twig' with {imgSrc: '/mail/common/limit.png', width: '270px'} %}
            {% include commonPath ~ '/block/spacer.html.twig' with {height: '15px'} %}
            {% embed commonPath ~ '/block/text.html.twig' with { sideMargins: 10 } %} 
            {# Bloki mogą przyjmować parametry np. wielkość marginesów, rozmiar czcionki itd. #}
                {% block text %}
                    {{ 'We are in the process of verifying the invoice financing application we have granted you a limit of %limit%'|trans({'%limit%': limit|money_currency}, 'mail')|raw  }}
                {% endblock %}
            {% endembed %}
            {% include commonPath ~ '/block/spacer.html.twig' with {height: '20px'} %}
            {% include layoutPath ~ '/block/login_button.html.twig' %}
            {% include commonPath ~ '/block/spacer.html.twig' with {height: '30px', backgroundColor: '#f8f8f8'} %} 
            {% embed commonPath ~ '/block/footer_first_text.html.twig' %}
                {% block text %}
                    {{ 'You received this email because you showed your willingness to fund your company with us.'|trans({}, 'mail') }}<br/>
                    {% include layoutPath ~ '/block/footer-contact.html.twig' ignore missing %}
                {% endblock %}
            {% endembed %}
        {% endblock %}
    {% endembed %}
{% endblock %}

Przykładowy layout:

<mjml>
    <mj-head>
        <mj-style>{# style, które pozwalają pozbyć się Blue Linków #}
            a[x-apple-data-detectors] {
            color: inherit !important;
            text-decoration: none !important;
            font-size: inherit !important;
            font-family: inherit !important;
            font-weight: inherit !important;
            line-height: inherit !important;
            }
            a {
            color: inherit !important;
            }
            .im {
            color: inherit !important;
            }
        </mj-style>
        <mj-style inline="inline">
            .cf {
            color: #b2b3b6 !important;
            }
            .cb {
            color: #58595A !important;
            }
        </mj-style>
        <mj-title>Title</mj-title>
        <mj-attributes>
            <mj-class name="no-border" border-style="hidden"/>
            <mj-all font-family="Verdana, Helvetica, sans-serif" color="#58595A"></mj-all>
            <mj-text align="center" font-weight="300" font-size="16px" line-height="24px"></mj-text>
            <mj-section padding="0px" background-color="#ffffff"></mj-section>
        </mj-attributes>
        <mj-preview>
            {{ block('preheader') }} {# renderujemy blok, który jest w templatkach niektórych maili #}
        </mj-preview>
    </mj-head>
    <mj-body background-color="#f8f8f8" width="700px">
        <mj-section background-color="#f8f8f8">
            <mj-column>
                <mj-spacer height="50px"/>
            </mj-column>
        </mj-section>
        {{ block('content') }} {# renderujemy blok, który jest w każdej templatce maila #}
        <mj-section align="center" background-color="#f8f8f8">
            <mj-column width="15%">
            </mj-column>
            <mj-column align="center" width="70%">
                <mj-table>
                    <tr style="list-style: none;line-height:1">
                        <td style="text-align: center">
                            <a href="https://www.facebook.com">
                                <img width="40" src="{{ CDN('/mail/common/socials/facebook-logo.png') }}"/>
                            </a>
                        </td>
                        <td style="text-align: center">
                            <a href="https://www.linkedin.com">
                                <img width="40" src="{{ CDN('/mail/common/socials/linkedin-logo.png') }}"/>
                            </a>
                        </td>
                        <td style="text-align: center">
                            <a href="https://www.slideshare.net">
                                <img width="40" src="{{ CDN('/mail/common/socials/slideshare-logo.png') }}"/>
                            </a>
                        </td>
                        <td style="text-align: center">
                            <a href="https://twitter.com">
                                <img width="40" src="{{ CDN('/mail/common/socials/twitter-logo.png') }}"/>
                            </a>
                        </td>
                        <td style="text-align: center">
                            <a href="https://www.youtube.com">
                                <img width="40" src="{{ CDN('/mail/common/socials/youtube-logo.png') }}"/>
                            </a>
                        </td>
                    </tr>
                </mj-table>
            </mj-column>
            <mj-column width="15%">
            </mj-column>
        </mj-section>
        <mj-section background-color="#f8f8f8">
            <mj-column>
                <mj-text color="#b2b3b6" line-height="1.3" font-size="11px">
                    ...
                </mj-text>
            </mj-column>
        </mj-section>
    </mj-body>
</mjml>

Przykładowy moduł:

{# text.html.twig #}
{% set defSideMargins = "7" %}
{% set defFontSize = '15px' %}
{% set defAlign = 'center' %}
{% set defBackgroundColor = '#ffffff' %}
{% set defColor = '#5A656F' %}
{% set defLineHeight = '1.3' %}
{% set defPadding = "0px" %}

{% if sideMargins is defined and sideMargins is not null and '0' == sideMargins %}
    {% set textSidePadding = false %}
{% endif %}

{% if fontSize is defined and fontSize is not null and fontSize %}
    {% if 'small' == fontSize %}
        {% set fontSize = '10px' %}
    {% elseif 'medium' == fontSize %}
        {% set fontSize = '15px' %}
    {% elseif 'large' == fontSize %}
        {% set fontSize = '17px' %}
    {% endif %}
{% endif %}

{% block text_outer %}
    <mj-section background-color="{{ backgroundColor|default(defBackgroundColor) }}">
        <mj-column width="{{ sideMargins|default(defSideMargins) }}%"></mj-column>
        {# Można zamiast pustej kolumny zrobić po prostu padding na <mj-text>,
         ale wtedy będzie też na wersji mobilnej, a tam go nie chcemy #}
        <mj-column width="{{ (100 - sideMargins|default(defSideMargins) * 2) }}%">
            <mj-text
                    font-size="{{ fontSize|default(defFontSize) }}"
                    align="{{ align|default(defAlign) }}"
                    color="{{ color|default(defColor) }}"
                    line-height="{{ lineHeight|default(defLineHeight) }}"
                    padding="{{ defPadding }}"
                    {% if isBold is defined and true == isBold %}
                        font-weight="bold"
                    {% else %}
                        font-weight="normal"
                    {% endif %}
                    {% if textSidePadding is defined and false == textSidePadding %}
                        padding-left="0px"
                        padding-right="0px"
                    {% endif %}
            >
                {{ block('text') }}
            </mj-text>
        </mj-column>
        <mj-column width="{{ sideMargins|default(defSideMargins) }}%"></mj-column>
    </mj-section>
{% endblock %}

Moduł, powtarzający się w wielu mailach:

{% embed '@BackendMailing/email/common/block/text.html.twig' with {size: 'small'} %}
    {# Ten blok korzysta z bloku text.html.twig, ale predefuniuje wartości zmiennych i treść #}
    {% block content %}
        {{ 'The message was generated automatically. Please do not reply.'|trans({},'mail') }}
    {% endblock %}
{% endembed %}

Aplikacja, podczas wykonywania procesu wysyłki e-maila, przygotowuje instancję klasy implementującej EmailMessageInterface i wrzuca ją do metody send głównego serwisu wysyłającego maile. Oczywiście wysyłkę każdego rodzaju e-maila przykryliśmy dedykowanym serwisem, którego jedynym zadaniem jest utworzenie obiektu Message i wrzucenie go do głównego serwisu wysyłki e-maili. Następnie, już w główny serwisie, wykonywane jest renderowanie: najpierw wybrana zostaje wersja, a na jej podstawie dobierane są bloki z odpowiedniej przestrzeni, aby we właściwy sposób stylować maila. Główny send service otrzymuje wyrenderowany przez silnik Twiga dokument MJML, puszcza go przez Renderer i w tym momencie body maila jest gotowe, a dalszy proces przebiega standardowo . 

I czas płynie dalej

Z perspektywy czasu trzeba przyznać, że nowych layoutów w czystym HTMLu nie dałoby się zakodować w krótkim czasie (zachowując przy tym zdrowie psychiczne). Być może można było rozegrać to lepiej — wynieść cały moduł mailingu do mikroserwisu, uniezależnić się od Symfony 2.8 i twiga, ale w trybie pożarowym nie powinno było i nie mogło się to zdarzyć. Jeśli myślimy o reagowaniu kryzysowym, to uważam, że cała ta akcja wyszła po mistrzowsku (a mikroserwis do maili i tak napiszemy, w kolejnym etapie). 

Pomysł z użyciem MJML to była oddolna inicjatywa i to właśnie lubię w Mint Software — nikt nie stawia barier dobrym pomysłom. 

MARIA, Software Engineer

Autor wpisu

Zobacz, w jaki sposób automatyzacja procesów zaowocowała wzrostem przychodów u naszych klientów.

Co możemy zrobić dla Ciebie ?

Masz pomysł, działający system, a może potrzebujesz dedykowanego oprogramowania bądź automatyzacji i robotyzacji procesów?

Sprawdź nasze usługi i umów się na spotkanie.