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ę.
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.
Szczegóły implementacji
Skorzystaliśmy z gotowego bundla: https://github.com/notFloran/mjml-bundle
Możliwości konfiguracji pod Symfony 2.8
- 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; - 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; - 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
Zalety:
- 15 minut zajęło nam napisanie działającej konfiguracji e-maili na środowisku lokalnym.
- Na środowisku produkcyjnym trzeba było dociągnąć node.js do kontenera z aplikacją, co zajęło kolejne 15 minut.
- Nie wysyłamy maili do żadnego zewnętrznego API: w ten sposób nie uzależniamy się od żadnych zewnętrznych systemów.
Uwagi:
- 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.
- 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
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.