Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
119 changes: 119 additions & 0 deletions docs/Layout/emailLayout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 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
```

> [!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

| 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 `<title>` 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.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions src/Component/Layout/EmailLayout.php
Original file line number Diff line number Diff line change
@@ -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');
}
}
41 changes: 41 additions & 0 deletions templates/layout/email_layout.html.twig
Original file line number Diff line number Diff line change
@@ -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 }}</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f4; font-family: Arial, Helvetica, sans-serif; color: #333333;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td align="center" style="padding: 24px 12px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="max-width: 600px; width: 100%; background-color: #ffffff; border-collapse: collapse;">
{% if logo %}
<tr>
<td align="center" style="padding: 24px 24px 0 24px;">
<img src="{{ email.image(logo) }}" alt="{{ copyrightHolder }}" style="display: block; max-width: 200px; height: auto; border: 0;">
</td>
</tr>
{% endif %}
{% block content %}{% endblock %}
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="max-width: 600px; width: 100%; border-collapse: collapse;">
<tr>
<td align="center" style="padding: 16px 24px; font-size: 12px; line-height: 1.5; color: #888888;">
<strong style="color: #555555;">{{ address }}</strong><br>
{{ addressLine }}
{% if noreply %}
<br><br>
<em>{{ noreply }}</em>
{% endif %}
<br><br>
&copy; {{ copyrightYear }} {{ copyrightHolder }}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
130 changes: 130 additions & 0 deletions tests/Component/Layout/EmailLayoutTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?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\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']);
}
}
Loading