`) must live inside `{% block content %}` because the outer wrapper is the email's main ``. 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 %}
+
+ |
+ Dear {{ user.displayName }},
+ You requested a password reset.
+
+
+ Reset password
+
+
+ |
+
+ {% endblock %}
+{% endcomponent %}
+```
+
+### Custom branding (other Enabel app, other holder)
+
+```twig
+{% component 'Enabel:Ux:EmailLayout' with {
+ logo: '@images/impala-logo.png',
+ copyrightHolder: 'Impala',
+} %}
+ {% block content %}
+ | … |
+ {% endblock %}
+{% endcomponent %}
+```
+
+### No logo (text-only email)
+
+```twig
+{% component 'Enabel:Ux:EmailLayout' with { logo: null } %}
+ {% block content %}
+ | … |
+ {% 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 @@
+
+ * 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 $data
+ *
+ * @return array
+ */
+ #[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 @@
+
+
+
+
+
+
+ {{ copyrightHolder }}
+
+
+
+
+
+
+ {% if logo %}
+
+
+
+ |
+
+ {% endif %}
+ {% block content %}{% endblock %}
+
+
+
+
+ {{ 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']);
+ }
+}
|