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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>`, never by attachment name**: twig-bridge 7.3 (the lowest-dependency legs) names the part with the whole Twig path and writes `cid:<that path>`, 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.
Expand Down
15 changes: 15 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@
->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)

Check warning on line 64 in src/DependencyInjection/Configuration.php

View workflow job for this annotation

GitHub Actions / PHPUnit (Symfony 7.4) (PHP 8.5)

Escaped Mutant for Mutator "IncrementInteger": @@ @@ ->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) + ->min(2) ->end() ->end() ->end()
->end()
->end()
->end()
->end();

$this->addMercureNode($rootNode);
Expand Down
18 changes: 11 additions & 7 deletions src/DependencyInjection/SilverbackApiComponentsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/Resources/views/emails/_template.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<spacer size="16"></spacer>

<center>
<img src="{{ email.image('@SilverbackApiComponents/assets/images/email_logo.png') }}" alt="Logo" class="logo">
<img src="{{ email.image(email_logo) }}" alt="{{ website_name }}" class="logo"{% if email_logo_width %} width="{{ email_logo_width }}" style="width: {{ email_logo_width }}px"{% endif %}>
</center>
</columns>
</row>
Expand Down
38 changes: 38 additions & 0 deletions tests/DependencyInjection/ConfigurationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{string, mixed}>
*/
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);
}
}
16 changes: 14 additions & 2 deletions tests/DependencyInjection/SilverbackApiComponentsExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -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
Expand All @@ -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'));
}
}
75 changes: 75 additions & 0 deletions tests/Functional/Email/EmailLogoTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

/*
* This file is part of the Silverback API Components Bundle Project
*
* (c) Daniel West <daniel@silverback.is>
*
* 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Binary file added tests/Functional/app/templates/emails/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading