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