From 655d472b2fb44d6738a20674b974fb4e22a40f34 Mon Sep 17 00:00:00 2001 From: Damien LAGAE Date: Thu, 28 May 2026 20:46:56 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Add=20EmailLayout=20layout=20co?= =?UTF-8?q?mponent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #17. Replaces the abandoned @EnabelLayout/emails/base.html.twig from enabel/layout-bundle. Provides a table-based 600px-wide HTML email layout with configurable header logo (via Symfony Mailer's email.image() helper), Enabel footer (address, no-reply notice, copyright) and a {% block content %} for the email body. --- config/services.yaml | 4 + docs/Layout/emailLayout.md | 116 ++++++++++++++++++ docs/index.md | 1 + src/Component/Layout/EmailLayout.php | 63 ++++++++++ templates/layout/email_layout.html.twig | 41 +++++++ tests/Component/Layout/EmailLayoutTest.php | 130 +++++++++++++++++++++ 6 files changed, 355 insertions(+) create mode 100644 docs/Layout/emailLayout.md create mode 100644 src/Component/Layout/EmailLayout.php create mode 100644 templates/layout/email_layout.html.twig create mode 100644 tests/Component/Layout/EmailLayoutTest.php diff --git a/config/services.yaml b/config/services.yaml index e86596c..2c885ea 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -63,3 +63,7 @@ services: Enabel\Ux\Component\Layout\ErrorPage: tags: - { name: 'twig.component', key: 'Enabel:Ux:ErrorPage', template: '@EnabelUx/layout/error_page.html.twig', expose_public_props: true } + + Enabel\Ux\Component\Layout\EmailLayout: + tags: + - { name: 'twig.component', key: 'Enabel:Ux:EmailLayout', template: '@EnabelUx/layout/email_layout.html.twig', expose_public_props: true } diff --git a/docs/Layout/emailLayout.md b/docs/Layout/emailLayout.md new file mode 100644 index 0000000..8fe3d30 --- /dev/null +++ b/docs/Layout/emailLayout.md @@ -0,0 +1,116 @@ +# EmailLayout Component + +## Description + +A reusable HTML email layout for Symfony Mailer's `TemplatedEmail`. Replaces the abandoned `@EnabelLayout/emails/base.html.twig` from `enabel/layout-bundle`. + +The layout is table-based with inline styles (the only thing every email client reliably renders), centered at 600px, with the Enabel-style address + no-reply notice + copyright footer. The body of the email is provided via the `{% block content %}` block. + +## Pre-requisite — Twig namespace for inline images + +The component renders the logo via Symfony Mailer's `email.image()` helper, which embeds the image inline (CID). The default logo path is `@images/enabel-logo-email.png`. Register the matching Twig namespace in your application: + +```yaml +# config/packages/twig.yaml +twig: + paths: + '%kernel.project_dir%/public/images': images +``` + +If you don't want inline embedding (or are not using `TemplatedEmail`), set `logo` to `null` and render the header yourself in the `content` block. + +## Parameters + +| Parameter | Type | Description | Default | +|:------------------|:----------|:-------------------------------------------------------------------------------------|:-------------------------------------------------| +| `logo` | `?string` | Twig-namespaced path passed to `email.image()`. Set to `null` to hide the header | `'@images/enabel-logo-email.png'` | +| `address` | `string` | Organisation name, bold in the footer | `'Belgian Development Agency'` | +| `addressLine` | `string` | Street + city line, below the address | `'Rue Haute 147 - 1000 Brussels'` | +| `noreply` | `?string` | No-reply notice in the footer. Set to `null` to hide it entirely | `'Responses to this e-mail will not be read.'` | +| `copyrightYear` | `?int` | Year in the copyright line. Resolves to current year when `null` | `null` (→ `date('Y')`) | +| `copyrightHolder` | `string` | Holder name shown after `©` and in the `` tag | `'Enabel'` | + +## Usage + +### Minimal email + +```twig +{# templates/emails/welcome.html.twig #} +{% component 'Enabel:Ux:EmailLayout' %} + {% block content %} + <tr> + <td style="padding: 24px;"> + <p>Welcome, {{ user.displayName }}.</p> + <p>Your account has been created.</p> + </td> + </tr> + {% endblock %} +{% endcomponent %} +``` + +Note: the inner table cells (`<tr><td>`) must live inside `{% block content %}` because the outer wrapper is the email's main `<table>`. This matches the table-based markup expected by Outlook and other strict clients. + +### Password reset with localized footer + +```twig +{# templates/emails/password_reset.html.twig #} +{% component 'Enabel:Ux:EmailLayout' with { + noreply: 'app.email.noreply'|trans, +} %} + {% block content %} + <tr> + <td style="padding: 24px;"> + <p>Dear {{ user.displayName }},</p> + <p>You requested a password reset.</p> + <p> + <a href="{{ url('app_reset_password', { token: resetToken.token }) }}" + style="display: inline-block; padding: 12px 24px; background-color: #333; color: #fff; text-decoration: none;"> + Reset password + </a> + </p> + </td> + </tr> + {% endblock %} +{% endcomponent %} +``` + +### Custom branding (other Enabel app, other holder) + +```twig +{% component 'Enabel:Ux:EmailLayout' with { + logo: '@images/impala-logo.png', + copyrightHolder: 'Impala', +} %} + {% block content %} + <tr><td style="padding: 24px;">…</td></tr> + {% endblock %} +{% endcomponent %} +``` + +### No logo (text-only email) + +```twig +{% component 'Enabel:Ux:EmailLayout' with { logo: null } %} + {% block content %} + <tr><td style="padding: 24px;">…</td></tr> + {% endblock %} +{% endcomponent %} +``` + +## Sending the email + +The component assumes the `email` Twig global is available, which is the case when the template is rendered through Symfony Mailer's `TemplatedEmail::htmlTemplate()`: + +```php +use Symfony\Bridge\Twig\Mime\TemplatedEmail; + +$email = (new TemplatedEmail()) + ->to($user->getEmail()) + ->subject('Welcome') + ->htmlTemplate('emails/welcome.html.twig') + ->context(['user' => $user]); + +$mailer->send($email); +``` + +If `logo` is set but the template is rendered outside an email context, Twig will fail with "Variable `email` does not exist." — set `logo: null` to disable the inline-image call. diff --git a/docs/index.md b/docs/index.md index b06580d..c71ec38 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,7 @@ https://github.com/Enabel/Ux ## Layouts - [Bootstrap](Layout/bootstrap.md) - A layout for rendering Bootstrap components with Enabel UX components & Enabel Bootstrap Theme +- [EmailLayout](Layout/emailLayout.md) - A reusable HTML email layout for Symfony Mailer's TemplatedEmail - [ErrorPage](Layout/errorPage.md) - A full-page error layout for Symfony's TwigBundle exception templates ## Install diff --git a/src/Component/Layout/EmailLayout.php b/src/Component/Layout/EmailLayout.php new file mode 100644 index 0000000..0a7db18 --- /dev/null +++ b/src/Component/Layout/EmailLayout.php @@ -0,0 +1,63 @@ +<?php + +/* + * This file is part of the Enabel UX package. + * Copyright (c) Enabel <https://enabel.be/> + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\Ux\Component\Layout; + +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\TwigComponent\Attribute\PreMount; + +class EmailLayout +{ + public ?string $logo; + public string $address; + public string $addressLine; + public ?string $noreply; + public int $copyrightYear; + public string $copyrightHolder; + + /** + * @param array<string, mixed> $data + * + * @return array<string, mixed> + */ + #[PreMount] + public function preMount(array $data): array + { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + + $resolved = $resolver->resolve($data); + + if (null === $resolved['copyrightYear']) { + $resolved['copyrightYear'] = (int) date('Y'); + } + + return $resolved + $data; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setIgnoreUndefined(); + $resolver->setDefaults([ + 'logo' => '@images/enabel-logo-email.png', + 'address' => 'Belgian Development Agency', + 'addressLine' => 'Rue Haute 147 - 1000 Brussels', + 'noreply' => 'Responses to this e-mail will not be read.', + 'copyrightYear' => null, + 'copyrightHolder' => 'Enabel', + ]); + + $resolver->setAllowedTypes('logo', ['string', 'null']); + $resolver->setAllowedTypes('address', 'string'); + $resolver->setAllowedTypes('addressLine', 'string'); + $resolver->setAllowedTypes('noreply', ['string', 'null']); + $resolver->setAllowedTypes('copyrightYear', ['int', 'null']); + $resolver->setAllowedTypes('copyrightHolder', 'string'); + } +} diff --git a/templates/layout/email_layout.html.twig b/templates/layout/email_layout.html.twig new file mode 100644 index 0000000..86f7f1d --- /dev/null +++ b/templates/layout/email_layout.html.twig @@ -0,0 +1,41 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <title>{{ copyrightHolder }} + + + + + + +
+ + {% if logo %} + + + + {% endif %} + {% block content %}{% endblock %} +
+ {{ copyrightHolder }} +
+ + + + +
+ {{ address }}
+ {{ addressLine }} + {% if noreply %} +

+ {{ noreply }} + {% endif %} +

+ © {{ copyrightYear }} {{ copyrightHolder }} +
+
+ + diff --git a/tests/Component/Layout/EmailLayoutTest.php b/tests/Component/Layout/EmailLayoutTest.php new file mode 100644 index 0000000..2d1a8ee --- /dev/null +++ b/tests/Component/Layout/EmailLayoutTest.php @@ -0,0 +1,130 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\Ux\Tests\Component\Layout; + +use Enabel\Ux\Component\Layout\EmailLayout; +use PHPUnit\Framework\TestCase; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; + +class EmailLayoutTest extends TestCase +{ + public function testComponentCanBeInstantiatedWithDefaultParameters(): void + { + $component = new EmailLayout(); + $data = $component->preMount([]); + + $component->logo = $data['logo']; + $component->address = $data['address']; + $component->addressLine = $data['addressLine']; + $component->noreply = $data['noreply']; + $component->copyrightYear = $data['copyrightYear']; + $component->copyrightHolder = $data['copyrightHolder']; + + $this->assertSame('@images/enabel-logo-email.png', $component->logo); + $this->assertSame('Belgian Development Agency', $component->address); + $this->assertSame('Rue Haute 147 - 1000 Brussels', $component->addressLine); + $this->assertSame('Responses to this e-mail will not be read.', $component->noreply); + $this->assertSame((int) date('Y'), $component->copyrightYear); + $this->assertSame('Enabel', $component->copyrightHolder); + } + + public function testComponentCanBeInstantiatedWithCustomParameters(): void + { + $component = new EmailLayout(); + $data = $component->preMount([ + 'logo' => '@images/custom-logo.png', + 'address' => 'Custom Agency', + 'addressLine' => 'Some Street 1 - 1000 City', + 'noreply' => 'Do not reply.', + 'copyrightYear' => 2020, + 'copyrightHolder' => 'Acme', + ]); + + $this->assertSame('@images/custom-logo.png', $data['logo']); + $this->assertSame('Custom Agency', $data['address']); + $this->assertSame('Some Street 1 - 1000 City', $data['addressLine']); + $this->assertSame('Do not reply.', $data['noreply']); + $this->assertSame(2020, $data['copyrightYear']); + $this->assertSame('Acme', $data['copyrightHolder']); + } + + public function testCopyrightYearDefaultsToCurrentYearWhenNullPassed(): void + { + $component = new EmailLayout(); + $data = $component->preMount(['copyrightYear' => null]); + + $this->assertSame((int) date('Y'), $data['copyrightYear']); + } + + public function testCopyrightYearPreservesExplicitValue(): void + { + $component = new EmailLayout(); + $data = $component->preMount(['copyrightYear' => 2010]); + + $this->assertSame(2010, $data['copyrightYear']); + } + + public function testLogoCanBeNull(): void + { + $component = new EmailLayout(); + $data = $component->preMount(['logo' => null]); + + $this->assertNull($data['logo']); + } + + public function testNoreplyCanBeNull(): void + { + $component = new EmailLayout(); + $data = $component->preMount(['noreply' => null]); + + $this->assertNull($data['noreply']); + } + + public function testInvalidLogoTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new EmailLayout(); + $component->preMount(['logo' => 123]); + } + + public function testInvalidAddressTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new EmailLayout(); + $component->preMount(['address' => 123]); + } + + public function testInvalidCopyrightYearTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new EmailLayout(); + $component->preMount(['copyrightYear' => '2024']); + } + + public function testInvalidCopyrightHolderTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new EmailLayout(); + $component->preMount(['copyrightHolder' => null]); + } + + public function testPreMountPreservesAdditionalData(): void + { + $component = new EmailLayout(); + $data = $component->preMount(['custom_attribute' => 'value']); + + $this->assertArrayHasKey('custom_attribute', $data); + $this->assertSame('value', $data['custom_attribute']); + } +} From b634d5180a5d7b1570c1a50b70d90902a88584a7 Mon Sep 17 00:00:00 2001 From: Damien LAGAE Date: Thu, 28 May 2026 20:56:43 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20Document=20that=20the=20cons?= =?UTF-8?q?umer=20must=20ship=20the=20logo=20asset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Layout/emailLayout.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/Layout/emailLayout.md b/docs/Layout/emailLayout.md index 8fe3d30..c068931 100644 --- a/docs/Layout/emailLayout.md +++ b/docs/Layout/emailLayout.md @@ -17,6 +17,9 @@ twig: '%kernel.project_dir%/public/images': images ``` +> [!IMPORTANT] +> The bundle does **not** ship the logo asset. The consuming application is responsible for placing `enabel-logo-email.png` (or whatever path is passed to `logo`) under the directory mapped to the `@images` namespace — e.g. `public/images/enabel-logo-email.png`. A missing asset will surface as `Unable to find template "@images/..."` at render time. + If you don't want inline embedding (or are not using `TemplatedEmail`), set `logo` to `null` and render the header yourself in the `content` block. ## Parameters