Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Processing Encore Files through inline_css()

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

We just used Encore to build an email.scss file that we want to process through inline_css() to style our emails. The problem is that, instead of building just one email.css file in public/build, it split it into two for performance reasons. That wouldn't be a problem, except that the way Webpack splits the files might change over time - we can't guarantee that it will always be these two files. To make matters worse, an Encore production build will add a dynamic "hash" to every file - like email.123abc.css.

Basically... pointing inline_css() directly at these two files... isn't going to work.

How Dynamic Files are Normally Rendered

This is why, in base.html.twig we simply use encore_entry_link_tags() and it takes care of everything. How? Behind the scenes, it looks in the public/build/ directory for an entrypoints.json file that Encore builds. This is the key: it tells us exactly which CSS and JS files are needed for each entrypoint - like app. Or, for email, yep! It contains the two CSS files.

The problem is that we don't want to just output link tags. We actually need to read the source of those files and pass that to inline_css().

Let's create a new Twig Function!

Since there's no built-in way to do that, let's make our own Twig function where we can say encore_entry_css_source(), pass it email, and it will figure out all the CSS files it needs, load their contents, and return it as one big, giant, beautiful string.

{% apply inky_to_html|inline_css(encore_entry_css_source('email')) %}
... lines 2 - 30
{% endapply %}

To create the function, our app already has a Twig extension called AppExtension. Inside, say new TwigFunction(), call it encore_entry_css_source and when this function is used, Twig should call a getEncoreEntryCssSource method.

... lines 1 - 13
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 16 - 24
public function getFunctions(): array
{
return [
... line 28
new TwigFunction('encore_entry_css_source', [$this, 'getEncoreEntryCssSource']),
];
}
... lines 32 - 75
}

Copy that name and create it below: public function getEncoreEntryCssSource() with a string $entryName argument. This will return the string CSS source.

... lines 1 - 13
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 16 - 53
public function getEncoreEntryCssSource(string $entryName): string
{
... lines 56 - 65
}
... lines 67 - 75
}

Inside, we need to look into the entrypoints.json file to find the CSS filenames needed for this $entryName. Fortunately, Symfony has a service that already does that. We can get it by using the EntrypointLookupInterface type-hint.

For reasons I don't want to get into in this tutorial, instead of using normal constructor injection - where we add an argument type-hinted with EntrypointLookupInterface - we're using a "service subscriber". You can learn about this in, oddly-enough, our tutorial about Symfony & Doctrine.

To fetch the service, go down to getSubscribedServices() and add EntrypointLookupInterface::class.

... lines 1 - 8
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
... lines 10 - 13
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 16 - 67
public static function getSubscribedServices()
{
return [
... lines 71 - 72
EntrypointLookupInterface::class,
];
}
}

Back up in getEncoreEntryCssSource(), we can say $files = $this->container->get(EntrypointLookupInterface::class) - that's how you access the service using a service subscriber - then ->getCssFiles($entryName).

... lines 1 - 13
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 16 - 53
public function getEncoreEntryCssSource(string $entryName): string
{
$files = $this->container
->get(EntrypointLookupInterface::class)
->getCssFiles($entryName);
... lines 59 - 65
}
... lines 67 - 75
}

This will return an array with something like these two paths. Next, foreach over $files as $file and, above create a new $source variable set to an empty string. All we need to do now is look for each file inside the public/ directory and fetch its contents.

... lines 1 - 13
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 16 - 53
public function getEncoreEntryCssSource(string $entryName): string
{
$files = $this->container
->get(EntrypointLookupInterface::class)
->getCssFiles($entryName);
$source = '';
foreach ($files as $file) {
... line 62
}
... lines 64 - 65
}
... lines 67 - 75
}

Adding a publicDir Binding

We could hardcode the path to the public/ directory right here. But instead, let's set up a new "binding" that we can pass through the constructor. Open up config/services.yaml. In our Symfony Fundamentals Course, we talk about how the global bind below _defaults can be used to allow scalar arguments to be autowired into our services. Add a new one: string $publicDir set to %kernel.project_dir% - that's a built-in parameter - /public.

... lines 1 - 12
services:
... line 14
_defaults:
... lines 16 - 22
bind:
... lines 24 - 27
string $publicDir: '%kernel.project_dir%/public'
... lines 29 - 54

This string part before $publicDir is optional. But by adding it, we're literally saying that this value should be passed if an argument is exactly string $publicDir. Being able to add the type-hint to a bind is a new feature in Symfony 4.2. We didn't use it on the earlier binds... but we could have.

Back in AppExtension, add the string $publicDir argument. I'll hit "Alt + Enter" and go to "Initialize fields" to create that property and set it.

... lines 1 - 13
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... line 16
private $publicDir;
... line 18
public function __construct(ContainerInterface $container, string $publicDir)
{
... line 21
$this->publicDir = $publicDir;
}
... lines 24 - 75
}

Down in the method, we can say $source .= file_get_contents($this->publicDir.$file) - each $file path should already have a / at the beginning. Finish the method with return $source.

Tip

To avoid missing CSS if you send your emails via Messenger (or if you send multiple emails during the same request), "reset" Encore's internal cache before calling getCssFiles():

// replace the first 3 lines with these
$entryPointLookupInterface = $this->container->get(EntrypointLookupInterface::class);
$entryPointLookupInterface->reset();
$files = $entryPointLookupInterface->getCssFiles($entryName);

$source = '';
// ...

... lines 1 - 13
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 16 - 53
public function getEncoreEntryCssSource(string $entryName): string
{
$files = $this->container
->get(EntrypointLookupInterface::class)
->getCssFiles($entryName);
$source = '';
foreach ($files as $file) {
$source .= file_get_contents($this->publicDir.'/'.$file);
}
return $source;
}
... lines 67 - 75
}

Whew! Let's try this! We're already running Encore... so it already dumped the email.css and vendors~email.css files. Ok, let's go send an email. I'll hit back to get to the registration page, bump the email, type any password, hit register and... wow! No errors! Over in Mailtrap... nothing here... Of course! We refactored to use Messenger... so emails are not sent immediately!

By the way, if that annoys you in development, there is a way to handle async messages immediately while coding. Check out the Messenger tutorial.

Let's start the worker and send the email. I'll open another tab in my terminal and run:

php bin/console messenger:consume -vv

Message received... and... message handled. Go check it out! The styling look great: they're inlined and coming from a proper Sass file.

And... we've made it to the end! You are now an email expert... I mean, not just a Mailer expert... we really dove deep. Congrats!

Go forth and use your great power responsibly. Let us know what cool emails you're sending... heck... you could even send them to us... and, as always, we're here to help down in the comments section.

Alright friends, seeya next time!

Leave a comment!

22
Login or Register to join the conversation

Hello!

After changing to encore_entry_css_source, inline_css does not work anymore. I have checked that the twig extension is successfully called (it found the css files and store them in source variable) but it's not included in the mail anymore. I am developping locally. Stop/start messenger consumer command, removed cache, ... but still not working. Any idea?

The twig extension:



	namespace App\Twig;

	use Psr\Container\ContainerInterface;
	use Symfony\Contracts\Service\ServiceSubscriberInterface;
	use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
	use Twig\Extension\AbstractExtension;
	use Twig\TwigFunction;

	class AppExtension extends AbstractExtension implements ServiceSubscriberInterface {

		private $container;
		private $publicDir;

		public function __construct(ContainerInterface $container, string $publicDir)
		{
			$this->container = $container;
			$this->publicDir = $publicDir;
		}

		public function getFunctions(): array
		{
			return [
				new TwigFunction('encore_entry_css_source', [$this, 'getEncoreEntryCssSource']),
			];
		}

		public function getEncoreEntryCssSource(string $entryName): string
		{
			$files = $this->container
				->get(EntrypointLookupInterface::class)
				->getCssFiles($entryName);

			$source = '';
			foreach ($files as $file) {
				$source .= file_get_contents($this->publicDir.'/'.$file);
			}

			return $source;
		}

		public static function getSubscribedServices()
		{
			return [
				EntrypointLookupInterface::class,
			];
		}

Thx!

1 Reply

Hey be_tnt!

So.... I am aware of a potential issue that you could be hitting. You don't have quite the setup I would expect for that problem... but it's close enough that I think it's the same.

First let me ask: it sounds like you are using messenger to send your emails asynchronously, is that correct? You are "routing" the messages to a messenger transport, then the emails are actually sent when you run the messenger:consume command, right?

The problem (I believe) is that your email is being rendered twice. And the second time it's rendered, because Encore thinks the CSS & JS have already been "output", the getCssFiles() method returns an empty array. You could verify that this the cause by adding this line right after the getCssFiles() call:


$this->container
    ->get(EntrypointLookupInterface::class)
    ->reset()

If that fixes the problem, then this is the problem :). Let me know if it helps - and then we can debug further. I mean, having the "reset" is a "fine" thing to keep there - it won't hurt anything (it just shouldn't be needed). From my research, there is a problem when Messenger is installed, but emails are still handed synchronously - ir I remember correctly, Mailer dispatches an event 2 times in one request... they both render the message... and voila! The second one has no CSS. It's (I believe) actually a subtle bug in Symfony that you only see when using Encore. But, let me know what you find out.

Cheers!

1 Reply

Hi weaverryan !

You were completely right. I am indeed sending the emails asynchronously with thee messenger:consume command. After adding the suggested line after the getCssFiles(), the css was inlined again in my mail :)

thx!

2 Reply
Default user avatar
Default user avatar boedah | weaverryan | posted 1 year ago | edited

Hey weaverryan!

Old post, but I stumbled upon the same error without using Messenger:
When a user submits a form and selects one special value, a first email is sent to the user AND a second one to an admin group - which then gets no styling...
This one was hard to catch, esp. as the mail rendered fine in a DevController to display all mails in an iframe and in tests.

I solved this by wrapping getEncoreEntryCssSource in a cache->get() call (AND resetting EntryPointLookupInterface ;)


    public function getEncoreEntryCssSource(string $entryName): string
    {
        $cache = $this->container->get(CacheInterface::class);
        $cacheKey = __FUNCTION__ . ';' . $entryName;

        return $cache->get($cacheKey, function (ItemInterface $item) use ($entryName) {
            $item->expiresAt(null);
            $entryPointLookupInterface = $this->container->get(EntrypointLookupInterface::class);
            $entryPointLookupInterface->reset();
            $source = '';
            foreach ($entryPointLookupInterface->getCssFiles($entryName) as $file) {
                $source .= file_get_contents($this->publicDir . '/' . $file);
            }

            return $source;
        });
    }

Regarding this lesson:
It is nice to have the "Tip" at the end, but:

  • it is not about Messenger at all (it happens when 2 or more mails are rendered in the same request) </li>
  • it should be a warning at the top </li>
  • maybe getEncoreEntryCssSource could also simply include the reset() call, so this warning is not even needed </li>

What do you think?

(jesus, Disqus should simply use Markdown :)

Reply

Hey boedah!

> (jesus, Disqus should simply use Markdown :)

Yea, we're replacing Disqus right now - we hate it too ;).

> * it is not about Messenger at all (it happens when 2 or more mails are rendered in the same request)

Good point!

> * maybe `getEncoreEntryCssSource` could also simply include the `reset()` call, so this warning is not even needed </li>

Even better point! I really like that idea!

I'm going to propose a modified note to use your solution. I appreciate it - great thinking!

Cheers!

1 Reply
Default user avatar
Default user avatar Florent Hazard | weaverryan | posted 2 years ago

Issue seems still there, I am not using Messenger and I got the same issues. Reset is required.
This is also processing twice.

Reply

Hey Florent,

Yes, you're right, you still need that reset(). It's not a bug, it's just a limitation... a compromise to make WebpackEncoreBundle great in other cases :)

Cheers!

Reply
Default user avatar
Default user avatar Florent Hazard | Victor | posted 2 years ago

Not a problem ;) I think it should appear in tutorial.

Reply

Hey Florent,

Agree! And it IS in the tutorial ;) See https://symfonycasts.com/sc...

Cheers!

Reply
Default user avatar
Default user avatar Florent Hazard | Victor | posted 2 years ago

This is another tutorial :D
I am not generating a PDF for now (but I will, surely)
And reset is not fixing the twice rendering.

Reply

Hey Florent,

I'm confused, we're talking on https://symfonycasts.com/sc... page, and I linked to https://symfonycasts.com/sc... page, so both pages are chapters of https://symfonycasts.com/sc... , or am I missing something here? :)

> And reset is not fixing the twice rendering.

Actually, the purpose of reset() is the opposite, every time you call reset and then render encore assets - it will render all the assets again. And this workaround is needed because Webpack Encore avoid rendering same assets twice.

Cheers!

Reply
Default user avatar

Is there more information on this issue anywhere? Why the double rendering when using Messenger? Thanks

Reply

Hey Benr77!

I believe this is the related issue - https://github.com/symfony/... - I've just bumped it :).

Cheers!

1 Reply
Default user avatar
Default user avatar sabat24 | posted 2 years ago

"First let me ask: it sounds like you are using messenger to send your emails asynchronously, is that correct?"
Even if you use messenger in sync mode, EntrypointLookup has to be reset.

What's more in SF 5.2 the ServiceSubscriberInterface doesn't work any more. EntrypointLookup should by DI in constructor.

Reply

Hi @sabat24!

> Even if you use messenger in sync mode, EntrypointLookup has to be reset

Yes, I think that may be correct: when Messenger is installed, even if you're sending the emails sync, Symfony does the odd double-render. I really need to make handling this easier in WebpackEncoreBundle (which I maintain), but it's a tricky issue to get right :/.

> What's more in SF 5.2 the ServiceSubscriberInterface doesn't work any more. EntrypointLookup should by DI in constructor

You can definitely inject through the constructor, but I'm not aware of any changes in Symfony 5.2 related to ServiceSusbcriberInterface - that interface still exists and should still work exactly like before. If you're having any trouble with it, let me know and we'll see if we can debug :).

Cheers!

Reply
Default user avatar
Default user avatar sabat24 | weaverryan | posted 2 years ago | edited

When I tried to use ServiceSusbcriberInterface I received following error:

<blockquote>Symfony\Component\DependencyInjection\Exception\

                ServiceNotFoundException                

The "Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface" service or alias has been removed or inlined when the container was compiled. You should either make it public, or stop using the container directly and use dependency injection instead.
</blockquote>

And this is the line which causes the problem


$files = $this->container
            ->get(EntrypointLookupInterface::class)

<blockquote>Container->make('Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface', 1)

        in
            D:\wamp\www\wildq2\vendor\symfony\dependency-injection\Container.php
                    (line 228)

</blockquote>


    public function get($id, int $invalidBehavior = /* self::EXCEPTION_ON_INVALID_REFERENCE */ 1)
    {
        return $this->services[$id]
            ?? $this->services[$id = $this->aliases[$id] ?? $id]
            ?? ('service_container' === $id ? $this : ($this->factories[$id] ?? [$this, 'make'])($id, $invalidBehavior));
    }
Reply
Default user avatar

You can ignore that error above. I used wrong ContainerInterface. It was DependencyInjection one instead of Psr\Container. Everything works fine.

1 Reply
Dominik Avatar
Dominik Avatar Dominik | posted 3 years ago | edited

Hi.

$mail = (new TemplatedEmail())
                ->addTo($to)
                ->addFrom($from)
                ->addBcc($bcc)
                ->addCc($cc)
                ->addReplyTo($rep)
                ->context($params)
                ->htmlTemplate($tempalte)
                ->subject($subje);

            $this->mailer->send($mail);```


After send "TemplatedEmail" via "MailerInterface" how can I get email htmlBody or as a text that been sended?

Thank you.
Reply

Hey Usuri,

If you send the email sync, then you should have access to the rendered HTML via $mail->getHtmlBody() method - call it right after the "$this->mailer->send($mail);" call. The renderer will render the template and set its HTML on the same email object.

If you send async - you probably need to do this via Messenger component.

I hope this helps!

Cheers!

Reply
Kiuega Avatar
Kiuega Avatar Kiuega | posted 3 years ago | edited

Hello !

In the event that the administrator of a site wishes to send a message to all of its customers, it would be useful to use "bcc" rather than recreating the email each time.

In function, this could result in


    public function sendAdminMessage($users, $subject, $body)
    {
        $this->resetEntryPoints();

        $email = (new TemplatedEmail());

        foreach($users as $user)
        {
            $email->addBcc(new NamedAddress($user->getEmail(), $user->getNom()));
        }

        $email->subject($subject)
        ->htmlTemplate('stripe/email/admin/admin_message_to_customer.html.twig')
        ->context([
            'body' => $body,
        ]);


        $this->mailer->send($email);

        return $email;
    }

That must be it if I don't say nonsense. (confirmation?)

However, in the Twig template, we will no longer be able to use {{email.toName}}

So my question is:
<b>"How to properly manage the sending of this kind of grouped emails"</b> so that each recipient receives the same email (but being able to include the recipient's name in the content each time as we did for {{email.toName}} ?

Reply

Hey Kiuega,

Good question! Well, BCC is not quite for this. When you use BCC - you suppose to send the exact same email to the email addresses specified in BCC, but in your case you're going to send a different unique emails. They are unique because of the unique user name in the email.

I think the only way to achieve "group emails" is to use Sendgrid API (or other service API you're using). For Sendgrid - take a look at Bulk Email Service: https://sendgrid.com/docs/g... . Usually, each email service gives you its own implementation of this, and it may vary from service to service.

Otherwise, you can try to make the email the same for every user and use BCC. Or, create unique emails for every user and send them separately. You may want to use Messenger integration in this case that will help with sending them async behind the scene.

I hope this helps!

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial is built on Symfony 4.3, but will work well with Symfony 4.4 or 5.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "aws/aws-sdk-php": "^3.87", // 3.110.11
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-snappy-bundle": "^1.6", // v1.6.0
        "knplabs/knp-time-bundle": "^1.8", // v1.9.1
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.23
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "league/html-to-markdown": "^4.8", // 4.8.2
        "liip/imagine-bundle": "^2.1", // 2.1.0
        "nexylan/slack-bundle": "^2.1,<2.2.0", // v2.1.0
        "oneup/flysystem-bundle": "^3.0", // 3.1.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.4.1
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.3.4
        "symfony/console": "^4.0", // v4.3.4
        "symfony/flex": "^1.9", // v1.17.6
        "symfony/form": "^4.0", // v4.3.4
        "symfony/framework-bundle": "^4.0", // v4.3.4
        "symfony/mailer": "4.3.*", // v4.3.4
        "symfony/messenger": "4.3.*", // v4.3.4
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.3.4
        "symfony/sendgrid-mailer": "4.3.*", // v4.3.4
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "^4.0", // v4.3.4
        "symfony/twig-pack": "^1.0", // v1.0.0
        "symfony/validator": "^4.0", // v4.3.4
        "symfony/web-server-bundle": "^4.0", // v4.3.4
        "symfony/webpack-encore-bundle": "^1.4", // v1.6.2
        "symfony/yaml": "^4.0", // v4.3.4
        "twig/cssinliner-extra": "^2.12", // v2.12.0
        "twig/extensions": "^1.5", // v1.5.4
        "twig/inky-extra": "^2.12" // v2.12.0
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.2.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/browser-kit": "4.3.*", // v4.3.5
        "symfony/debug-bundle": "^3.3|^4.0", // v4.3.4
        "symfony/dotenv": "^4.0", // v4.3.4
        "symfony/maker-bundle": "^1.0", // v1.13.0
        "symfony/monolog-bundle": "^3.0", // v3.4.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.3.4
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "^3.3|^4.0" // v4.3.4
    }
}
userVoice