Отправка электронной почты в формате HTML

    Введение


    Почти в каждом проекте приходится думать об отправке писем по электронной почте. Основными требованиями при этом являются, помимо надежности доставки, привлекательность и удобство электронных писем.


    Основные нюансы при формировании таких писем:


    • Все стили должны встраиваться (inline) в виде атрибута style для конкретного HTML-элемента.
    • Все изображения должны встраиваться, либо как отдельные вложения в в письме, либо в виде base64-кодированных данных (второе банально удобнее).
    • Письмо должно поддерживать DKIM (настройка мэйлера), а домен отправителя — содержать SPF-запись.

    Ранее я использовал для формирования HTML-писем проект Premailer, созданный на Ruby. Пришлось даже заняться поддержкой проекта (сейчас времени на это нет, мэйнтейнеры приветствуются).


    Сейчас же хотелось избежать внедрения Ruby, в то время, как Node проник везде.


    Juice


    К счастью, современная экосистема Node предоставляет богатые возможности по формированию электронных писем. Мы выбрали цепочку по формированию электронной почты в виде pug-шаблонов, преобразованию оных с помощью juice и подстановки конкретных данных на бэкэнде (у нас это Perl).


    Предполагается, что Вы используете node 6+, babel (es2015, es2016, es2017, stage-0 presets).


    Установка


    npm install gulp-cli -g
    npm install gulp --save-dev
    npm install del --save-dev
    npm install gulp-rename --save-dev
    npm install gulp-pug --save-dev
    npm install premailer-gulp-juice --save-dev
    npm install gulp-postcss --save-dev
    npm install autoprefixer --save-dev
    npm install gulp-less --save-dev

    gulpfile.babel.js:


    'use strict';
    
    import gulp from 'gulp';
    import mail from './builder/tasks/mail';
    gulp.task('mail', mail);

    builder/tasks/mail.js:


    'use strict';
    
    import gulp from 'gulp';
    import stylesheets from './mail/stylesheets';
    import templates from './mail/templates';
    import clean from './mail/clean';
    
    const mail = gulp.series(clean, stylesheets, templates);
    
    export default mail;

    builder/tasks/mail/stylesheets.js


    'use strict';
    
    import gulp from 'gulp';
    import config from 'config';
    import rename from 'gulp-rename';
    import postcss from 'gulp-postcss';
    import autoprefixer from 'autoprefixer';
    import less from 'gulp-less';
    
    const stylesheetsPath = config.get('srcPath') + '/mail/stylesheets';
    
    const stylesheetsGlob = stylesheetsPath + '/**/*.less';
    
    const mailStylesheets = () => {
      return gulp.src(stylesheetsGlob)
        .pipe(less())
        .pipe(postcss([
          autoprefixer({browsers: ['last 2 versions']}),
        ]))
        .pipe(gulp.dest(stylesheetsPath));
    };
    
    export default mailStylesheets;

    builder/tasks/mail/templates.js:


    'use strict';
    
    import gulp from 'gulp';
    import config from 'config';
    import pug from 'gulp-pug';
    import rename from 'gulp-rename';
    import juice from 'premailer-gulp-juice';
    
    const templatesPath = config.get('srcPath') + '/mail';
    const mailPath = config.get('mailPath');
    
    const templatesGlob = templatesPath + '/**/*.pug';
    
    const mailTemplates = () => {
      return gulp.src(templatesGlob)
        .pipe(rename(path => {
          path.extname = '.html';
        }))
        .pipe(pug({
          client: false
        }))
        .pipe(juice({
          webResources: {
            relativeTo: templatesPath,
            images: 100,
            strict: true
          }
        }))
        .pipe(gulp.dest(mailPath));
    };
    
    export default mailTemplates;

    builder/tasks/mail/clean.js:


    'use strict';
    
    import del from 'del';
    import gutil from 'gulp-util';
    
    const clean = done => {
      return del([
        'mail/*.html',
        'src/mail/stylesheets/*.css'
      ]).then(() => {
        gutil.log(gutil.colors.green('Delete src/mail/stylesheets/*.css and mail/*.html'));
        done();
      });
    };
    
    export default clean;

    Типичный шаблон выглядит так (generic.pug):


    include base.pug
    
    +base
        tr(height='74')
            td.b-mail__table-row--heading(align='left', valign='top') Привет,
        tr
            td(align='left', valign='top')
                | <%== $html %>

    Где base.pug:


    mixin base(icon, alreadyEncoded)
        doctype html
        head
            meta(charset="utf8")
            link(rel="stylesheet", href="/stylesheets/mail.css")
        body
            table(width='100%', border='0', cellspacing='0', cellpadding='0')
                tbody
                    tr
                        td.b-mail(align='center', valign='top', bgcolor='#ffffff')
                            br
                            br
                            table(width='750', border='0', cellspacing='0', cellpadding='0')
                                tbody.b-mail__table
                                    tr.b-mail__table-row(height='89')
                                    tr.b-mail__table-row
                                        td(align='left', valign='top', width='70')
                                            img(src='/images/logo.jpg')
                                        td(align='left', valign='top')
                                            table(width='480', border='0', cellspacing='0', cellpadding='0')
                                                tbody
                                                    if block
                                                        block
                                        td(align='right', valign='top')
                                            if alreadyEncoded
                                                img.fixed(src!=icon, data-inline-ignore)
                                            else if icon
                                                img.fixed(src!=icon)
                            br
                            br
                    tr
                        td(align='center', valign='top')

    Собственно, болванка готова, шаблоны компилируются. Формирование модуля config тривиально и необязательно.


    Готовая болванка репозитория здесь: https://github.com/premailer/gulp-juice-demo


    gulp mail

    ViewAction и т.п.


    Многие почтовые клиенты, такие, как GMail/Inbox, поддерживают специальные действия в режиме просмотра сообщений. Внедрить их проще простого, добавив в содержимое сообщения следующие тэги:


    div(itemscope, itemtype="http://schema.org/EmailMessage")
      div(itemprop="action", itemscope, itemtype="http://schema.org/ViewAction")
        link(itemprop="url", href="https://github.com/imlucas/gulp-juice/pull/9")
        meta(itemprop="name", content="View Pull Request")
      meta(itemprop="description", content="View this Pull Request on GitHub")

    Подробнее можно прочесть в разделе Email Markup.


    Ну и немного интеграции с (выберите свой язык, тут нужен был Perl)


    sub prepare_mail_params {
        my %params = %{ shift() };
    
        my @keys = keys %params;
        # Camelize params
        for my $param ( @keys ) {
            my $new_param = $param;
            $new_param =~ s/^(\w)/\U$1\E/;
            next  if $new_param eq $param;
            $params{$new_param} = delete $params{$param};
        }
    
        %params = (
            Type     => 'multipart/mixed; charset=UTF-8',
            From     => 'support@ourcompany.co.uk',
            Subject  => '',
            %params,
        );
    
       # Mime params
        for my $param ( keys %params ) {
            $params{$param} = encode( 'MIME-Header', $params{$param} );
        }
    
        return \%params;
    }
    
    sub _template_processor {
        state $instance = Mojo::Template->new(
            vars        => 1,
            auto_escape => 1,
        );
        return $instance;
    }
    
    sub send_mail {
        my %params = %{ shift() };
    
        my $html =  (delete $params{message}) // '';
    
        my $template =  delete $params{template};
        my $stash = (delete $params{stash}) // {};
        unless ( $template ) {
            $template = 'generic';
            $stash->{html} = $html;
        }
    
        $html = _template_processor()->render_file(
            Config->directories->{mail}. "/$template.html",
            $stash,
        );
    
        $html = encode_utf8( $html );
    
        my $msg = MIME::Lite->new(
            %{ prepare_mail_params( \%params ) }
        );
    
        $msg->attach(
            Type => 'HTML',
            Data => $html,
        );
    
        if ( $mail_settings->{method} eq 'sendmail' ) {
            return $msg->send();
        }
    
        if ( $mail_settings->{method} eq 'smtp' ) {
            return $msg->send('smtp', $mail_settings->{host}, Timeout => $mail_settings->{timeout});
        }
    
        croak "Unknown Config mail.method: ". $mail_settings->{method};
    }

    Полезные ссылки



    P.S.: Спасибо pstn за доработки шаблонов писем.

    Поделиться публикацией

    Комментарии 22

      0
      Посмотрите на http://foundation.zurb.com/emails.html
      У них свои html теги, позволяют избавиться от миллиона вложенных таблиц в шаблонах. У меня в двух проектах с успехом работают, письма отображаются идеально на всех веб-клиентах и Apple системах. Есть небольшие проблемы с андроидом, огромные проблемы с аутлуком, как и всегда.
        0

        Спасибо, интересное развитие Ink. Собственно, его CSS-часть вполне удобно использовать (Foundation for Emails 2) совместно.


        Остальное чуть позже гляну.

          0
          Поэтому у нас в компании все ещё верстают руками, без инструментов. Зато письма смотрятся одинаково хорошо, как на телефонах, так и на компах, даже в аутлуке письма выглядят точно также, как на макете. Сборщики не могут учесть всех особенностей.
            0
            Если не секрет, покажите скрин какого-то письма?
              0
              Для всеобщей публикации не подходящее письмо выбрал, может найду что-то другое. Но в ЛС отправил.
          • НЛО прилетело и опубликовало эту надпись здесь
              0
              Мы тестируем через litmus, там их нет, поэтому не знаю. Напрягу тестировщиков в понедельник, не подумал об этом, спасибо. Я на айфоне пользуюсь клиентом email, в нем все ок. Подозреваю, что в айфонах должно быть все ок, а с андроида у нас 4% входов всего.
                0
                Проверили на иос сторонние клиенты. Яху немного поехал, но не критично, все остальные в норме. Разве что некоторые не смогли понять, что нужно показать мобильную версию, а показали десктопную.

                Мейл не проверяли — у нас стартап для штатов и мейла ни у кого нет.

                Плюс еще проблема в том, что у нас письма нарисовал какой-то крутой американский дизайнер, который жил в своем отдельном дизайнерском мире и проблемы верстки писем его совсем не волновали. Нам пришлось подстраиваться под него.
              0
              В чем преимущества перед https://mjml.io/?
                0

                Интересный язык разметки — MJML.


                Надо казать, что это не замена, а удобный препроцессор, на замену тому же gulp-pug.


                Благо предоставляется пакет gulp-mjml.

                  0

                  Вот как можно переделать


                  // header.mjml.pug
                  mj-section
                    mj-column
                      mj-text This is a header

                  далее gulp-pug, gulp-mjml, и напоследок juice.


                  На самом деле весь juice и не нужен, достаточно сделать gulp-обёртку вокруг web-resource-inliner.

                  +2
                  Картинки как вложения? Вы вообще ненормальные? Вложения к письмам — это прямой путь в чёрные списки. Не говоря уж о base64, когда в Gmail режутся письма больше 100кб. Если использовать все советы из статьи, гарантировано подучишь бан на домен или IP. Не надо так.
                    –1

                    Обычно в письма вкладывают одну или две картинки, для придания узнаваемого стиля.


                    Никаких проблем при этом со спамом не возникает.

                      0
                      Вложения к письмам могут быть при написании одного-двух писем, как это делается с подписью и стилем. Но если речь о массовой рассылке, а статья именно для массовой, то вложения недопустимы.
                        0

                        Начнем с того, что массовые рассылки лучше поручать отдельным компаниям, которые оными занимаются.


                        И да, речи о массовых рассылках в статье нет.

                          0
                          Все, что отправляется с сайта, рано или поздно становится массовым. Речь не идёт о миллионных рассылках. Если вы отправляете 500+ писем в день, ваши письма начнут считать массовыми. Даже для самого мелкого проекта это небольшая цифра. И вот тогда начнутся большие проблемы со спам-фильтрами. Очень много умельцев было, что под видом вложений рассылали троянов. В связи с этим вложения стали чаще блокироваться, вместе с письмами. Внешние же ссылки, стали проходить сторонние антивирусные проверки. Вы отправляете письмо, Яндекс видит ссылки на картинки или вложения, отправляет их на проверку в DrWeb, и уже потом пересылает в ящик пользователя.
                            0

                            Я работал какое-то время в REG.RU, и там у нас есть периодически рассылки по базе клиентов (много тысяч клиентов в день, внедренные изображения).


                            В общем, никаких особых проблем нет абсолютно. Все решалось SPF, DKIM, разносом отправки на несколько IP-адресов. Никакой магии.

                              0
                              Возможно, когда-то и было. Но сейчас даже письма от того же REG.ru приходят без вложений. Я говорю про нынешнее положение дел, раньше может и прокатывало. А сейчас разнос писем на разные IP не даст толку, потому что спам-фильтры стали анализировать контент, а не только входные данные.
                                +1

                                Да, смотрю, инлайнить изображения уже не требуется.

                    +1
                    Внедрить их проще простого, добавив в содержимое сообщения следующие тэги:

                    Ооо, а я почему-то думал что это личная инициатива гугла для некоторых писем поддерживать такие специальные ссылки.
                    Спасибо:)

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое