Use Inky and Sendgrid to Send Beautiful Emails

June 21, 2023
Written by
Joseph Udonsak
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Laravel Gates. The Ultimate Guide

You cannot overemphasise the importance of emails in modern-day communication.

Software applications have latched on to this and integrated email for sending notifications. There's just one problem: designing beautiful, dynamic emails can be a pain due to the myriad of email clients, browsers, and mobile devices with which one can view emails.

The accepted best practice is to use tables and extensive inline styling to beautify the email, but this makes for complex templates which can be difficult to debug and modify.

But it doesn't have to be that way. In this article, using Symfony, Sengrid, and the Inky framework, I will show you how to design responsive email layouts without the complexity. I will also show you how to inline external CSS files to make your templates easier to work with.

To top it all off, I will show you how to implement asynchronous email dispatching. You will do this by building a simple application with only one page, a form which allows you to type in a message and the recipient(s).

Prerequisites

To follow this tutorial, you will need the following things:

Set up the project

Create a new project folder named inky_demo and navigate into it using the following commands.

symfony new inky_demo
cd inky_demo

Create an email template with Inky

The first thing you’ll work on is the template for the email your application will send. As mentioned earlier, Inky allows you to include external CSS in your email template. These CSS files will be stored in the project's public folder. To create this directory structure, run the following command.

mkdir -p public/css/email

Skip the -p argument if you're using Microsoft Windows.

This command creates a new folder named css which holds all the CSS assets for the application, and in it another folder named email which holds all the CSS assets for email related functionality.

Next, download a compressed archived of the Foundation CSS files and uncompress it. Then, copy the two CSS files from the css folder in the uncompressed archive to public/css/email.

After doing so, the structure of the public folder should match the following.

public/
└── css/
    └── email/
        ├── foundation-emails.css
        └── foundation.css
└── index.php

Next, add the project's runtime and development dependencies. These include Twig for templating, Inky, Twig CssInliner Extension for inlining your CSS, and the Symfony MailerMaker, Messenger and Profiler bundles. Install them by running the following commands.

composer require asset doctrine mailer messenger symfony/doctrine-messenger symfony/http-client symfony/sendgrid-mailer twig twig/cssinliner-extra twig/inky-extra -W
composer require --dev maker profiler

Answer n when prompted with the following message

The recipe for this package contains some Docker configuration.

This may create/update docker-compose.yml or update Dockerfile (if it exists).

Do you want to include Docker configuration from recipes?
[y] Yes
[n] No
[p] Yes permanently, never ask again for this project
[x] No permanently, never ask again for this project
(defaults to y):

At this point a folder named templates will have been created for you. This folder will hold the Twig templates that will be used by your application. In this folder, create a new folder named email. Then, in the templates/email folder create a new file named inky.html.twig and add the following to it.

{% apply inky_to_html | inline_css(source('@styles/foundation-emails.css')) %}
    <style>
        .signature-heading {
            font-weight: bold;
            color: #b42912;
        }

        .padded {
            margin-top: 40px;
        }
    </style>
    <container>
        <row>
            <columns>
                <div class="padded">
                    {% for paragraph in paragraphs %}
                        <p>{{ paragraph }}</p>
                    {% endfor %}
                </div>
            </columns>
        </row>
        <row>
            <columns small="12" large="3" class="large-offset-1">
                <img class="small-float-center" src="https://get.foundation/emails/docs/assets/img/inky-class.png"
                     alt="please don't forget me">
            </columns>
            <columns small="12" large="8">
                <h4 class="small-text-center signature-heading">What is the deal?</h4>
                <p class="small-text-center" style="color: #1b9448">
                    Use Foundation for Emails to design responsive HTML
                    emails that work in any email client. Learn more
                    <a href="https://get.foundation/emails/docs/index.html">here</a>
                </p>
            </columns>
        </row>
    </container>
{% endapply %}

The inline_css filter allows you to include external stylesheets in your template while the inky_to_html filter is used by Twig to process your Inky template into regular html i.e., including the tables and rows, etc. These filters are chained and applied to the template via the apply tag.

You can also apply styles directly to HTML documents. This shows you how Inky doesn’t require you to learn anything new to use it. It’s only extracting tedious boilerplate code, allowing you to focus on the email content itself.

This template expects only one variable named paragraphs. This is an array and each element in the array will be rendered within a <p> tag.

When specifying the source for the foundation-emails.css file, note that instead of a full path, the file name was prepended with @styles. This refers to a template namespace. You will need to specify which folder this namespace is referring to.

To do this, open the Twig configuration located in config/packages/twig.yaml and update it to match the following.

hl_lines="3,4"
twig:
    default_path: '%kernel.project_dir%/templates'
    paths:
        'public/css/email': 'styles'

when@test:
    twig:
        strict_variables: true

In the paths declaration, the location of the email CSS files is specified as a key, while the namespace is specified as a value.

Now you have a beautiful template, but you still need a way of sending the emails. The next step is to create a form which can be used to send emails to one or more people with any message you want.

Set up Sendgrid and Mailer

Before you can send emails via SendGrid you need to log in to your account and set up a Sender Identity. If you already have one, however, you can skip to the next section where you will use your SendGrid credentials in your application.

For this tutorial, you will take the Single Sender Verification approach in setting up a Sender Identity. Log in to the SendGrid console. Then, under Settings > Sender Authentication click Verify a Single Sender to start the process. Fill the form displayed on the screen and click Create.

new-sendgrid-sender

A verification email will be sent to the email address you provided in the form. Click on the verification link to complete the verification process.

verify-single-sender

Once the verification process is completed, open Settings > API Keys and click the Create API Key button. Fill out the displayed form and click the Create & View button in order to view your API Key.

create-api-key

The next screen will show your API key.

For security reasons, it will only be shown once so make sure you copy it before clicking DONE.

Next, add the Symfony SendGrid Mailer Bridge component using the following command.

composer require symfony/sendgrid-mailer

After that, add your Sendgrid API key to the application's environment variables. To do that, create a .env.local file from the .env file, which Symfony generated during creation of the project, by running the command below.

cp .env .env.local

Then, update the relevant values in .env.local as shown below.

MAILER_DSN=sendgrid+api:<YOUR_SENDGRID_API_KEY>@default
SENDER_EMAIL=<YOUR_SENDGRID_SENDER_EMAIL>
SENDER_NAME=<YOUR_SENDGRID_SENDER_NAME>

Bind the sender email and sender name to the service container by updating config/services.yaml to match the following.

hl_lines="8,10"
parameters:

services:
    _defaults:
        autowire: true    
        autoconfigure: true 
        
        bind:
            $senderEmail: '%env(resolve:SENDER_EMAIL)%'
            $senderName: '%env(resolve:SENDER_NAME)%'

    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'

Create the send email form

Create a new controller using the following command.

symfony console make:controller Index

This creates a new file named IndexController in the src/controller folder. It also creates a new template located in templates/index/index.html.twig. With it created, update the base template in templates/base.html.twig to match the following.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>InkyMail| {% block title %}{% endblock %}</title>
    <link rel="icon" href="https://symfony.com/favicons/favicon.svg">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
          rel="stylesheet"
          integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
          crossorigin="anonymous">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
            integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
            crossorigin="anonymous"></script>
    {% block stylesheets %}
        {{ encore_entry_link_tags('app') }}
    {% endblock %}
</head>
<body>
<div class="container">
    {% block body %}{% endblock %}
</div>

{% block javascripts %}
    {{ encore_entry_script_tags('app') }}
{% endblock %}
</body>
</html>

This adds Bootstrap (via CDN) to your templates and adds aesthetic value to the form you’re about to create.

Next, update the newly created templates/index/index.html.twig to match the following.

{% extends 'base.html.twig' %}

{% block title %} InkyMail{% endblock %}

{% block body %}
    <div class="my-5 mx-auto" style="width: 60%;">
        <h2>InkyMail</h2>
        {% for message in app.flashes('success') %}
            <div class="alert alert-success" role="alert">
                {{ message }}
            </div>
        {% endfor %}
        <form class="needs-validation" novalidate id="form" method="post">
            <div class="mb-3">
                <label for="message" class="form-label">Message</label>
                <textarea class="form-control" id="message" rows="10" name="message" required></textarea>
            </div>
            <div class="recipients my-3">
                <h3 class="fs-6">Recipient(s)</h3>
                <div class="row g-3 mb-3 recipientInput" id="recipient_0">
                    <div class="col-5">
                        <input type="email" class="form-control" placeholder="Enter an email" name="recipients[0]"
                               required>
                        <div class="invalid-feedback">
                            Please provide the recipient's email in a valid format.
                        </div>
                    </div>
                    <div class="col">
                        <div class="input-group">
                            <button class="btn" type="button" onclick="handleAddRecipientButtonClick()">
                                <i class="bi bi-patch-plus text-success" style="font-size: 1.3rem"></i>
                            </button>
                        </div>
                    </div>
                </div>
            </div>
            <button type="submit" class="btn btn-primary">Submit</button>
        </form>
    </div>
{% endblock %}


{% block javascripts %}
    <script src="{{ asset('js/recipient.js') }}"></script>
    <script src="{{ asset('js/formValidator.js') }}"></script>
{% endblock %}

The form consists of a text area where the user can type in the content of the email, and a text box for the email address of the recipient. This form is dynamic and allows for the addition of more than one email address.

At the bottom of the template, you will notice that two Javascript files are referenced in the javascripts block. The next step is to add them.

In the public folder, create a new folder named js which will hold all JavaScript-related code. Next, in the public/js folder, create two new files named recipient.js and formValidator.js. Add the following to recipient.js.

const form = document.getElementById("form");
let recipientCount = document.getElementsByClassName("recipients").length;

const getRecipientFieldJSX = (index) => `
        <div class="col-5">
            <input type="email" class="form-control" placeholder="Enter an email" name="recipients[${index}]" required>
            <div class="invalid-feedback">
                Please provide the recipient's email in a valid format.
            </div>
        </div>
        <div class="col">
            <div class="input-group">
                <button class="btn" type="button" onclick="handleAddRecipientButtonClick()">
                    <i class="bi bi-patch-plus text-success" style="font-size: 1.3rem"></i>
                </button>
                <button class="btn" type="button" onclick="deleteRecipientField(${index})">
                    <i class="bi bi-trash3 text-danger" style="font-size: 1.3rem"></i>
                </button>
            </div>
        </div>
`;

const handleAddRecipientButtonClick = () => {
  const div = document.createElement("div");
  div.setAttribute("class", "row g-3 mb-3 recipientInput");
  div.setAttribute("id", `recipient_${recipientCount}`);
  div.innerHTML = getRecipientFieldJSX(recipientCount);
  const lastChild = document.getElementById(`recipient_${recipientCount - 1}`);
  lastChild.after(div);
  recipientCount++;
};

const deleteRecipientField = (index) => {
  const recipient = document.getElementById(`recipient_${index}`);
  recipient.parentNode.removeChild(recipient);
  recipientCount--;
};

The handleAddRecipientButtonClick() and deleteRecipientField() functions respond to button clicks for adding and removing fields respectively. The HTML for the new input field is created via the getRecipientFieldJSX() function.

Next, add the following to public/js/formValidator.js.

(function () {
  "use strict";

  const forms = document.querySelectorAll(".needs-validation");

  Array.prototype.slice.call(forms).forEach(function (form) {
    form.addEventListener(
      "submit",
      function (event) {
        if (!form.checkValidity()) {
          event.preventDefault();
          event.stopPropagation();
        }

        form.classList.add("was-validated");
      },
      false
    );
  });
})();

This script is used to validate the form client side before sending the submitted data to the backend.

Next, update src/Controller/IndexController.php to match the following.

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Annotation\Route;

#[Route('/', name: 'app_')]
class IndexController extends AbstractController {

    #[Route('', name: 'index', methods: ['GET', 'POST'])]
    public function index(Request $request, MailerInterface $mailer, string $senderEmail, string $senderName)
    : Response {

        if ($request->isMethod(Request::METHOD_POST)) {
            $paragraphs = preg_split("/\r\n|\n|\r/", $request->request->get('message'));
            $recipients = $request->request->all('recipients');
            $sender = new Address($senderEmail, $senderName);
            $htmlContent = $this->renderView('email/inky.html.twig', ['paragraphs' => $paragraphs]);

            foreach ($recipients as $recipient) {
                $email = (new Email())
                    ->from($sender)
                    ->to($recipient)
                    ->subject('With love from Inky ❤️')
                    ->html($htmlContent);
                $mailer->send($email);
            }

            $this->addFlash('success', 'Emails sent successfully');
        }

        return $this->render('index/index.html.twig');
    }
}

The index() function is responsible for rendering the form and handling the submitted response. When the form is submitted, the message parameter is retrieved and split into multiple paragraphs. The array of recipients is also retrieved, and for each recipient the injected mailer is used to send an email with the rendered Inky template as the email body.

To see this in action, run the application with the following command.

symfony serve

By default, the application is served on port 8000. Open it in your browser to test your application. Type in a message and then include one or two email addresses as recipients. Next submit the form and wait till you get the success message: Emails sent successfully.

Asynchronous messaging

At the moment, the emails have to be sent before the success message is rendered. This impacts the waiting time before the success message is displayed - the more recipients, the longer the waiting time.

Also, if there were to be some downtime with the email provider, the user would encounter an error page. Even worse, they would have to restart the process for something that wasn’t their fault. Needless to say, sending notifications this way could negatively impact user experience. This is why notifications are sent asynchronously.

Asynchronous messaging means that the emails are queued and handled separately while the application continues its execution process. This gives the user the impression that things are happening instantly. It also provides a failsafe in the event that something beyond the user’s control goes wrong.

For example, if there is a downtime with the service provider and the email cannot be sent, it is transferred to a failed queue where the support team can investigate and retry once the issue is resolved.

The Messenger component will be used to achieve this, in conjunction with Doctrine. This will allow you to use a database as the transport for asynchronous processing of your messages.

SQLite will be used for the database. In a new terminal session, create a new file named data.db in the var folder with the following command.

touch var/data.db

Next, in .env.local update the DATABASE_URL environment variable to match the following configuration.

DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"

Then, update config/packages/messenger.yaml to match the following.

framework:
    messenger:
        failure_transport: failed

        transports:
             async: '%env(MESSENGER_TRANSPORT_DSN)%'
             failed: 'doctrine://default?queue_name=failed'

        routing:

Next, set up your transports using the following command.

symfony console messenger:setup-transports

This creates a new table named messenger_messages in the database.

Next, create a new message class named NotifyRecipients using the following command.

symfony console make:message NotifyRecipients

When prompted, route the message to the async transport by pressing 1 as shown below.

Which transport do you want to route your message to? [[no transport]]:
[0] [no transport]
[1] async
[2] failed
> 1

This command creates two new files: src/Message/NotifyRecipients.php which contains the message and src/MessageHandler/NotifyRecipientsHandler.php which handles the message. The message will contain the email content and an array of recipients, while the handler will contain the code to be executed in order to handle the message.

Update the code in src/Message/NotifyRecipients.php to match the following.

<?php

namespace App\Message;

final class NotifyRecipients 
{
    public function __construct(
        private readonly string $content,
        private readonly array  $recipients
    ) {
    }

    public function getContent() : string 
    {

        return $this->content;
    }

    public function getRecipients(): array 
    {

        return $this->recipients;
    }

}

In general, messages don’t contain any logic. They are holders for the information the message handler requires to function appropriately.

Next, update src/MessageHandler/NotifyRecipientsHandler.php to match the following code.

<?php

namespace App\MessageHandler;

use App\Message\NotifyRecipients;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Twig\Environment;

#[AsMessageHandler(fromTransport: 'async')]
final class NotifyRecipientsHandler 
{
    private Address $sender;

    public function __construct(
        private readonly Environment     $twig,
        private readonly MailerInterface $mailer,
        string                           $senderEmail,
        string                           $senderName
    ) {

        $this->sender = new Address($senderEmail, $senderName);
    }

    public function __invoke(
        NotifyRecipients $message
    ) {

        $paragraphs = preg_split("/\r\n|\n|\r/", $message->getContent());
        $htmlContent = $this->twig->render('email/inky.html.twig', ['paragraphs' => $paragraphs]);
        $recipients = $message->getRecipients();
        foreach ($recipients as $recipient) {
            $email = (new Email())
                ->from($this->sender)
                ->to($recipient)
                ->subject('With love from Inky ❤️')
                ->html($htmlContent);
            $this->mailer->send($email);
        }
    }
}

The __invoke() function is called to handle the NotifyRecipients message. This function receives the message as a parameter. The body of this function looks similar to the body of the index() function in the controller. Instead of retrieving the email content and recipients  from a request, they are retrieved from the message. Otherwise, the code is the same.

Next, update src/Controller/IndexController.php to dispatch a message and remove the code that has been moved to the message handler. It should match the code below.  

<?php

namespace App\Controller;

use App\Message\NotifyRecipients;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;

#[Route('/', name: 'app_')]
class IndexController extends AbstractController {

    #[Route('', name: 'index', methods: ['GET', 'POST'])]
    public function index(Request $request, MessageBusInterface $bus)
    : Response {

        if ($request->isMethod(Request::METHOD_POST)) {
            $content = $request->request->get('message');
            $recipients = $request->request->all('recipients');
            $bus->dispatch(new NotifyRecipients($content, $recipients));
            
            $this->addFlash('success', 'Emails sent successfully');
        }

        return $this->render('index/index.html.twig');
    }
}

With this in place, the email creation will be handled asynchronously, but not dispatch of the email. To fix this, route the SendEmailMessage to the async transport. In the config/packages/messenger.yaml file, update the routing configuration to match the following.

routing:
    App\Message\NotifyRecipients: async
    Symfony\Component\Mailer\Messenger\SendEmailMessage: async

Now, both the creation and dispatch processes are handled asynchronously. The next thing to do is to consume the messages for the async transport. Do this by running the following command.

symfony console messenger:consume async -vv

With your application running, try sending a few more emails out. Notice that, this time, regardless of how many recipients you type in the success message is displayed immediately, and the messages are queued and handled accordingly as shown in the terminal.

Conclusion

The main aim of this article was to show you an easier alternative to building email templates for Symfony applications by taking advantage of Inky. In conjunction with Twig filters that allow you to inline external CSS files, you were able to come up with a template that is simpler to manage. To top things off, you also saw how easy it is to integrate SendGrid into a Symfony application, and handle email generation and dispatch asynchronously.

In case you get stuck at any point, feel free to access the codebase here. I’m excited to see what more you come up with. Until next time ✌🏾

Joseph Udonsak is a software engineer with a passion for solving challenges – be it building applications, or conquering new frontiers on Candy Crush. When he’s not staring at his screens, he enjoys a cold beer and laughs with his family and friends.