diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f5c8b23..5127c55a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@ Every change that reaches `main` adds a line under **Unreleased**. When a versio
## Unreleased
+### Added
+- `emails.logo` (a Twig path, default the bundle's logo) and `emails.logo_width` (pixels, optional) set the logo at the top of every email the bundle sends, whose alt text is now the `website_name` instead of `Logo` ([#576](https://github.com/components-web-app/api-components-bundle/pull/576))
+
### Fixed
- A user referenced by IRI from another resource's write is no longer processed as a new registration, which reset their `emailAddressVerified` ([#575](https://github.com/components-web-app/api-components-bundle/pull/575))
diff --git a/CLAUDE.md b/CLAUDE.md
index 7056efbe..f3607ea1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -299,6 +299,7 @@ Resources are fetched and cached **individually**. Never embed related data outs
- **User email links** (#316): `RefererUrlResolver` uses `Origin`, else `Referer`, reduced to `scheme://host[:port]`, only if it matches `user.email_links.allowed_origins` (anchored by the bundle as `{\A(?:p)\z}i`), else `default_origin`, else the link is refused. The request host is not implicitly trusted. Request input may only be a `RelativeUrlPath`. **Never build an outbound link from a request header without an allow-list.**
- **A refused email link depends on which kind of email it is** (#326). Where the email is the job (password reset, both resends), the request is a 400 that changes nothing: build and send the email before flushing the token it carries. After a completed write (every email from `UserWriteNotifier::notify()`), the write keeps its status and `UserMailer::afterWrite()` logs the refusal at error; the verify and change-email emails serve both kinds, so the notifier must call their `…AfterWrite` variants. Only `UnparseableRequestHeaderException` is caught.
- **A throttled email request is a 429 with `Retry-After`** (#331), never a 200 that sends nothing. `UserDataProcessor` throws `RequestLimitReachedException` carrying the seconds remaining; the action returns it with nothing sent or changed. Each flow has its own `repeat_ttl_seconds`: `password_reset` (86400), `new_email_confirmation` (300) and `email_verification` (300). Never share one throttle between flows.
+- **Every bundle email extends `emails/_template.html.twig`, so every sender's context carries `website_name`, `email_logo` and `email_logo_width`** (`SilverbackApiComponentsExtension::emailContext()`, #573). `emails.logo` is a Twig path, because `email.image()` loads it through the Twig loader; a filesystem path fails. **Find an embedded image from the `
`, never by attachment name**: twig-bridge 7.3 (the lowest-dependency legs) names the part with the whole Twig path and writes `cid:`, 7.4+ uses the basename and a generated content id, and the lowest CSS inliner puts the element's own `style` before the inlined rules. `Email\EmailLogoTest` follows the `src` to the part whose content id or filename it names.
- **A failed send returns false whether or not a logger is wired** (#340): the logger is optional in `UserMailer`'s service locator, so check `has('logger')`; `get()` on a missing id throws.
- **The `…RequestedAt` timestamp starts the throttle, so `UserMailer` sets it only after `send()` succeeds**, and flushes it with the token then. A failed send (503) saves nothing: the action refreshes the user, so the token is not replaced by one nobody received.
- Actions calling `UserDataProcessor::findUserByUsername()` must catch its `InvalidArgumentException` and return 404; it throws rather than returning null.
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index 5b4d31a0..090d1ddf 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -50,6 +50,21 @@ public function getConfigTreeBuilder(): TreeBuilder
->scalarNode('website_name')->isRequired()->end()
->scalarNode('table_prefix')->defaultValue('_acb_')->end()
->scalarNode('metadata_key')->defaultValue('_metadata')->end()
+ ->arrayNode('emails')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->scalarNode('logo')
+ ->info('The Twig path of the logo at the top of every email the bundle sends.')
+ ->cannotBeEmpty()
+ ->defaultValue('@SilverbackApiComponents/assets/images/email_logo.png')
+ ->end()
+ ->integerNode('logo_width')
+ ->info('The width in pixels the logo is shown at, so a 2x image renders sharp. Null shows it at its own width.')
+ ->defaultNull()
+ ->min(1)
+ ->end()
+ ->end()
+ ->end()
->end();
$this->addMercureNode($rootNode);
diff --git a/src/DependencyInjection/SilverbackApiComponentsExtension.php b/src/DependencyInjection/SilverbackApiComponentsExtension.php
index 240ea0ec..94d1951c 100644
--- a/src/DependencyInjection/SilverbackApiComponentsExtension.php
+++ b/src/DependencyInjection/SilverbackApiComponentsExtension.php
@@ -178,7 +178,7 @@ public function load(array $configs, ContainerBuilder $container): void
$definition = $container->findDefinition(OrphanedResourcesChangedEmailFactory::class);
$definition->setArgument('$subject', $config['orphaned_resources']['notify']['subject']);
- $definition->setArgument('$context', ['website_name' => $config['website_name']]);
+ $definition->setArgument('$context', self::emailContext($config));
$definition = $container->findDefinition(UploadableCacheControl::class);
$definition->setArgument('$original', $config['uploadable']['cache_control']['original']);
@@ -319,15 +319,19 @@ private function setUserClassArguments(ContainerBuilder $container, string $user
$definition->setArgument('$userClass', $userClass);
}
+ private static function emailContext(array $config): array
+ {
+ return [
+ 'website_name' => $config['website_name'],
+ 'email_logo' => $config['emails']['logo'],
+ 'email_logo_width' => $config['emails']['logo_width'],
+ ];
+ }
+
private function setMailerServiceArguments(ContainerBuilder $container, array $config): void
{
$definition = $container->findDefinition(UserMailer::class);
- $definition->setArgument(
- '$context',
- [
- 'website_name' => $config['website_name'],
- ]
- );
+ $definition->setArgument('$context', self::emailContext($config));
$mapping = [
PasswordChangedEmailFactory::class => 'password_changed',
diff --git a/src/Resources/views/emails/_template.html.twig b/src/Resources/views/emails/_template.html.twig
index 71d4077c..dba63ff9 100644
--- a/src/Resources/views/emails/_template.html.twig
+++ b/src/Resources/views/emails/_template.html.twig
@@ -6,7 +6,7 @@
-
+
diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php
index c85ce7a3..44fedc5b 100644
--- a/tests/DependencyInjection/ConfigurationTest.php
+++ b/tests/DependencyInjection/ConfigurationTest.php
@@ -848,4 +848,42 @@ public function test_an_empty_orphaned_resource_admin_page_path_is_rejected(): v
$this->expectException(InvalidConfigurationException::class);
$this->process($config);
}
+
+ public function test_emails_show_the_bundle_logo_at_its_own_width_by_default(): void
+ {
+ self::assertSame(
+ ['logo' => '@SilverbackApiComponents/assets/images/email_logo.png', 'logo_width' => null],
+ $this->process(self::minimalConfig())['emails']
+ );
+ }
+
+ public function test_the_email_logo_and_its_width_can_be_configured(): void
+ {
+ $config = self::minimalConfig();
+ $config['emails'] = ['logo' => 'emails/logo.png', 'logo_width' => 200];
+
+ self::assertSame(['logo' => 'emails/logo.png', 'logo_width' => 200], $this->process($config)['emails']);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function invalidEmailLogos(): iterable
+ {
+ yield 'an empty logo' => ['logo', ''];
+ yield 'a null logo' => ['logo', null];
+ yield 'a zero width' => ['logo_width', 0];
+ yield 'a negative width' => ['logo_width', -1];
+ yield 'a width with a unit' => ['logo_width', '200px'];
+ }
+
+ #[DataProvider('invalidEmailLogos')]
+ public function test_an_invalid_email_logo_is_rejected(string $key, mixed $value): void
+ {
+ $config = self::minimalConfig();
+ $config['emails'][$key] = $value;
+
+ $this->expectException(InvalidConfigurationException::class);
+ $this->process($config);
+ }
}
diff --git a/tests/DependencyInjection/SilverbackApiComponentsExtensionTest.php b/tests/DependencyInjection/SilverbackApiComponentsExtensionTest.php
index f455e5da..bfa7c9f6 100644
--- a/tests/DependencyInjection/SilverbackApiComponentsExtensionTest.php
+++ b/tests/DependencyInjection/SilverbackApiComponentsExtensionTest.php
@@ -602,7 +602,7 @@ public function test_the_mailer_subjects_default_and_can_be_overridden(): void
self::assertSame('Please confirm your new email address', $this->argument($container, ChangeEmailConfirmationEmailFactory::class, '$subject'));
self::assertSame('Welcome to {{ website_name }}', $this->argument($container, WelcomeEmailFactory::class, '$subject'));
self::assertTrue($this->argument($container, WelcomeEmailFactory::class, '$enabled'));
- self::assertSame(['website_name' => 'Test Website'], $this->argument($container, UserMailer::class, '$context'));
+ self::assertSame(['website_name' => 'Test Website', 'email_logo' => '@SilverbackApiComponents/assets/images/email_logo.png', 'email_logo_width' => null], $this->argument($container, UserMailer::class, '$context'));
self::assertTrue($this->argument($container, PasswordResetEmailFactory::class, '$enabled'));
self::assertTrue($this->argument($container, ChangeEmailConfirmationEmailFactory::class, '$enabled'));
@@ -723,7 +723,7 @@ public function test_orphaned_resource_notifications_are_wired_with_no_recipient
self::assertSame([], $this->argument($container, OrphanedResourceNotifier::class, '$recipients'));
self::assertSame('/_cwa/orphaned', $this->argument($container, OrphanedResourceNotifier::class, '$adminPagePath'));
self::assertSame('Orphaned resources changed on {{ website_name }}', $this->argument($container, OrphanedResourcesChangedEmailFactory::class, '$subject'));
- self::assertSame(['website_name' => 'Test Website'], $this->argument($container, OrphanedResourcesChangedEmailFactory::class, '$context'));
+ self::assertSame(['website_name' => 'Test Website', 'email_logo' => '@SilverbackApiComponents/assets/images/email_logo.png', 'email_logo_width' => null], $this->argument($container, OrphanedResourcesChangedEmailFactory::class, '$context'));
}
public function test_orphaned_resource_notification_configuration_reaches_the_notifier(): void
@@ -741,4 +741,16 @@ public function test_orphaned_resource_notification_configuration_reaches_the_no
self::assertSame('/admin/orphans', $this->argument($container, OrphanedResourceNotifier::class, '$adminPagePath'));
self::assertSame('Orphans', $this->argument($container, OrphanedResourcesChangedEmailFactory::class, '$subject'));
}
+
+ public function test_the_configured_email_logo_reaches_every_email_sender(): void
+ {
+ $config = self::minimalConfig();
+ $config['emails'] = ['logo' => 'emails/logo.png', 'logo_width' => 200];
+
+ [$container] = $this->load($config);
+
+ $expected = ['website_name' => 'Test Website', 'email_logo' => 'emails/logo.png', 'email_logo_width' => 200];
+ self::assertSame($expected, $this->argument($container, UserMailer::class, '$context'));
+ self::assertSame($expected, $this->argument($container, OrphanedResourcesChangedEmailFactory::class, '$context'));
+ }
}
diff --git a/tests/Functional/Email/EmailLogoTest.php b/tests/Functional/Email/EmailLogoTest.php
new file mode 100644
index 00000000..9703a253
--- /dev/null
+++ b/tests/Functional/Email/EmailLogoTest.php
@@ -0,0 +1,75 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Silverback\ApiComponentsBundle\Tests\Functional\Email;
+
+use Silverback\ApiComponentsBundle\Tests\Functional\FunctionalTestCase;
+use Silverback\ApiComponentsBundle\Tests\Functional\Orphan\OrphanedResourcesTrait;
+use Silverback\ApiComponentsBundle\Tests\Functional\User\UserTrait;
+use Symfony\Bridge\Twig\Mime\TemplatedEmail;
+use Symfony\Component\Mime\Part\DataPart;
+
+final class EmailLogoTest extends FunctionalTestCase
+{
+ use OrphanedResourcesTrait;
+ use UserTrait;
+
+ private const string CONFIGURED_LOGO = __DIR__ . '/../app/templates/emails/logo.png';
+
+ public function test_a_user_email_shows_the_configured_logo_at_the_configured_width_with_the_site_name_as_its_alt_text(): void
+ {
+ $client = static::createClient();
+ self::createUserWithRole('my_username', self::generatedSecret(), 'ROLE_USER');
+
+ self::requestJsonLd($client, 'GET', '/password/reset/request/my_username', null, ['referer' => 'http://www.website.com']);
+
+ self::assertResponseStatusCodeSame(200);
+ self::assertShowsTheConfiguredLogo(self::assertOneTemplatedEmailSentTo('test.user@example.com'));
+ }
+
+ public function test_the_orphaned_resources_email_shows_the_configured_logo_at_the_configured_width_with_the_site_name_as_its_alt_text(): void
+ {
+ self::createOrphanedComponentGroup();
+
+ $scan = self::runScheduledOrphanScan();
+
+ self::assertCount(1, $scan['notifications']);
+ self::assertShowsTheConfiguredLogo($scan['notifications'][0]);
+ }
+
+ private static function assertShowsTheConfiguredLogo(TemplatedEmail $email): void
+ {
+ $document = new \DOMDocument();
+ $document->loadHTML((string) $email->getHtmlBody(), \LIBXML_NOERROR);
+ $images = (new \DOMXPath($document))->query('//img[contains(concat(" ", @class, " "), " logo ")]');
+ self::assertNotFalse($images);
+ self::assertCount(1, $images);
+ $image = $images->item(0);
+ self::assertInstanceOf(\DOMElement::class, $image);
+ self::assertSame('New Website', $image->getAttribute('alt'));
+ self::assertSame('200', $image->getAttribute('width'));
+ $widths = [];
+ foreach (explode(';', $image->getAttribute('style')) as $declaration) {
+ $parts = array_map(trim(...), explode(':', $declaration, 2));
+ if ('width' === $parts[0]) {
+ $widths[] = $parts[1];
+ }
+ }
+ self::assertSame(['200px'], $widths);
+
+ $reference = $image->getAttribute('src');
+ self::assertStringStartsWith('cid:', $reference);
+ $reference = substr($reference, 4);
+ $logos = array_values(array_filter($email->getAttachments(), static fn (DataPart $part): bool => \in_array($reference, [$part->getContentId(), $part->getFilename()], true)));
+ self::assertCount(1, $logos);
+ self::assertSame(file_get_contents(self::CONFIGURED_LOGO), $logos[0]->getBody());
+ }
+}
diff --git a/tests/Functional/app/config/packages/silverback_api_components.yaml b/tests/Functional/app/config/packages/silverback_api_components.yaml
index 90d3f377..0455432f 100644
--- a/tests/Functional/app/config/packages/silverback_api_components.yaml
+++ b/tests/Functional/app/config/packages/silverback_api_components.yaml
@@ -7,6 +7,9 @@ silverback_api_components:
ttl: 604800 # 1 week
database_user_provider: database
website_name: New Website
+ emails:
+ logo: emails/logo.png
+ logo_width: 200
user:
class_name: Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\User
email_links:
diff --git a/tests/Functional/app/templates/emails/logo.png b/tests/Functional/app/templates/emails/logo.png
new file mode 100644
index 00000000..2f1bf47b
Binary files /dev/null and b/tests/Functional/app/templates/emails/logo.png differ