From 80c4745f64d7900643a58df6302d9ede4d73e5ee Mon Sep 17 00:00:00 2001 From: silverbackdan Date: Sat, 31 Oct 2026 15:41:21 +0000 Subject: [PATCH 1/3] Configure the email logo and its width, with the site name as alt text (#573) --- CHANGELOG.md | 3 + CLAUDE.md | 1 + src/DependencyInjection/Configuration.php | 15 ++++ .../SilverbackApiComponentsExtension.php | 18 +++-- .../views/emails/_template.html.twig | 2 +- .../DependencyInjection/ConfigurationTest.php | 38 ++++++++++ .../SilverbackApiComponentsExtensionTest.php | 16 ++++- tests/Functional/Email/EmailLogoTest.php | 66 ++++++++++++++++++ .../packages/silverback_api_components.yaml | 3 + .../Functional/app/templates/emails/logo.png | Bin 0 -> 3467 bytes 10 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 tests/Functional/Email/EmailLogoTest.php create mode 100644 tests/Functional/app/templates/emails/logo.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c32523ff..7fcb6ff69 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` ([#574](https://github.com/components-web-app/api-components-bundle/pull/574)) + ## [2.0.0-alpha.39](https://github.com/components-web-app/api-components-bundle/compare/2.0.0-alpha.38...2.0.0-alpha.39) - 2026-10-30 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index d900bd891..c1cda18aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -298,6 +298,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. - **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 5b4d31a0a..090d1ddf9 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 240ea0ec2..94d1951cb 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 71d4077ca..dba63ff96 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 c85ce7a35..44fedc5b5 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 f455e5da1..bfa7c9f6e 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 000000000..dbd7b3046 --- /dev/null +++ b/tests/Functional/Email/EmailLogoTest.php @@ -0,0 +1,66 @@ + + * + * 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 + { + $logos = array_values(array_filter($email->getAttachments(), static fn (DataPart $part): bool => 'logo.png' === $part->getFilename())); + self::assertCount(1, $logos); + $logo = $logos[0]; + self::assertSame(file_get_contents(self::CONFIGURED_LOGO), $logo->getBody()); + + $document = new \DOMDocument(); + $document->loadHTML((string) $email->getHtmlBody(), \LIBXML_NOERROR); + $images = (new \DOMXPath($document))->query(\sprintf('//img[@src="cid:%s"]', $logo->getContentId())); + 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')); + self::assertStringEndsWith('; width: 200px;', $image->getAttribute('style')); + } +} diff --git a/tests/Functional/app/config/packages/silverback_api_components.yaml b/tests/Functional/app/config/packages/silverback_api_components.yaml index 90d3f377c..0455432fd 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 0000000000000000000000000000000000000000..2f1bf47b8ccafa1cfe6575b9773ea738b12fcb78 GIT binary patch literal 3467 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 literal 0 HcmV?d00001 From 8349f78e017d3ff2a99923d9417b48128c7151b9 Mon Sep 17 00:00:00 2001 From: silverbackdan Date: Sat, 31 Oct 2026 15:41:36 +0000 Subject: [PATCH 2/3] Link the changelog line to the PR (#576) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fcb6ff69..042486d4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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` ([#574](https://github.com/components-web-app/api-components-bundle/pull/574)) +- `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)) ## [2.0.0-alpha.39](https://github.com/components-web-app/api-components-bundle/compare/2.0.0-alpha.38...2.0.0-alpha.39) - 2026-10-30 From 0b6ceb6f6bcc9499311ca186f9865a9d8c1a335c Mon Sep 17 00:00:00 2001 From: silverbackdan Date: Sat, 31 Oct 2026 15:56:44 +0000 Subject: [PATCH 3/3] Find the email logo from its img, so the test holds on twig-bridge 7.3 (#576) --- CLAUDE.md | 2 +- tests/Functional/Email/EmailLogoTest.php | 23 ++++++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c1cda18aa..bdecab4ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -298,7 +298,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. +- **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/tests/Functional/Email/EmailLogoTest.php b/tests/Functional/Email/EmailLogoTest.php index dbd7b3046..9703a2538 100644 --- a/tests/Functional/Email/EmailLogoTest.php +++ b/tests/Functional/Email/EmailLogoTest.php @@ -47,20 +47,29 @@ public function test_the_orphaned_resources_email_shows_the_configured_logo_at_t private static function assertShowsTheConfiguredLogo(TemplatedEmail $email): void { - $logos = array_values(array_filter($email->getAttachments(), static fn (DataPart $part): bool => 'logo.png' === $part->getFilename())); - self::assertCount(1, $logos); - $logo = $logos[0]; - self::assertSame(file_get_contents(self::CONFIGURED_LOGO), $logo->getBody()); - $document = new \DOMDocument(); $document->loadHTML((string) $email->getHtmlBody(), \LIBXML_NOERROR); - $images = (new \DOMXPath($document))->query(\sprintf('//img[@src="cid:%s"]', $logo->getContentId())); + $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')); - self::assertStringEndsWith('; width: 200px;', $image->getAttribute('style')); + $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()); } }