Skip to content

Commit 44cfbe8

Browse files
authored
Fix emails (#2)
1 parent 3397f6b commit 44cfbe8

6 files changed

Lines changed: 141 additions & 32 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
.idea
1+
.idea
2+
.claude
3+
composer.lock
4+
vendor

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
"doctrine/dbal": "^3.8 || ^4.0",
1212
"symfony/form": "^7.4",
1313
"symfony/http-foundation": "^7.4",
14+
"symfony/mailer": "^7.4",
15+
"symfony/mime": "^7.4",
16+
"symfony/password-hasher": "^7.4",
1417
"symfony/security-core": "^7.4",
1518
"symfony/translation-contracts": "^3.0",
1619
"symfony/validator": "^7.4",

src/Controller/ContentElement/MultiStepRegistrationElementController.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Symfony\Component\Form\Flow\DataStorage\SessionDataStorage;
2222
use Symfony\Component\Form\FormError;
2323
use Symfony\Component\Form\FormFactoryInterface;
24+
use Symfony\Component\Form\Flow\FormFlowInterface;
2425
use Symfony\Component\HttpFoundation\RedirectResponse;
2526
use Symfony\Component\HttpFoundation\Request;
2627
use Symfony\Component\HttpFoundation\RequestStack;
@@ -101,6 +102,7 @@ protected function getResponse(FragmentTemplate $template, ContentModel $model,
101102
$attributes[$field] = $this->fieldMapper->createAttributes($config);
102103
}
103104

105+
/** @var FormFlowInterface $flow */
104106
$flow = $this->formFactory->createNamed('multi_step_registration_'.$model->id, MemberRegistrationFlowType::class, $data, [
105107
'steps' => $steps,
106108
'dca_fields' => $dcaFields,

src/Form/DcaFormFieldMapper.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
2020
use Symfony\Component\Form\Extension\Core\Type\TextType;
2121
use Symfony\Component\Form\Extension\Core\Type\UrlType;
22+
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
2223
use Symfony\Component\Validator\Constraints\Callback;
2324
use Symfony\Component\Validator\Constraints\Email;
2425
use Symfony\Component\Validator\Constraints\Length;
@@ -28,8 +29,10 @@
2829

2930
class DcaFormFieldMapper
3031
{
31-
public function __construct(private readonly Connection $connection)
32-
{
32+
public function __construct(
33+
private readonly Connection $connection,
34+
private readonly PasswordHasherFactoryInterface $passwordHasherFactory,
35+
) {
3336
}
3437

3538
/**
@@ -200,8 +203,7 @@ private function normalizeValue(string $field, array $dca, mixed $value): mixed
200203
}
201204

202205
if ('password' === ($dca['inputType'] ?? null) && \is_string($value) && '' !== $value) {
203-
$value = System::getContainer()
204-
->get('security.password_hasher_factory')
206+
$value = $this->passwordHasherFactory
205207
->getPasswordHasher(FrontendUser::class)
206208
->hash($value);
207209
}

src/Registration/MemberRegistrationService.php

Lines changed: 81 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
use Contao\CoreBundle\Event\MemberActivationMailEvent;
1010
use Contao\CoreBundle\OptIn\OptInInterface;
1111
use Contao\CoreBundle\OptIn\OptInToken;
12-
use Contao\Email;
12+
use Contao\CoreBundle\Routing\ContentUrlGenerator;
13+
use Contao\CoreBundle\String\SimpleTokenParser;
1314
use Contao\Environment;
1415
use Contao\FilesModel;
1516
use Contao\Folder;
@@ -23,8 +24,12 @@
2324
use Contao\System;
2425
use Contao\Versions;
2526
use Psr\Log\LoggerInterface;
27+
use Symfony\Component\DependencyInjection\Attribute\Autowire;
2628
use Symfony\Component\HttpFoundation\RedirectResponse;
2729
use Symfony\Component\HttpFoundation\Request;
30+
use Symfony\Component\Mailer\MailerInterface;
31+
use Symfony\Component\Mime\Address;
32+
use Symfony\Component\Mime\Email;
2833
use Symfony\Component\Routing\RouterInterface;
2934
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
3035
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -35,7 +40,14 @@ public function __construct(
3540
private readonly OptInInterface $optIn,
3641
private readonly EventDispatcherInterface $eventDispatcher,
3742
private readonly RouterInterface $router,
43+
private readonly ContentUrlGenerator $contentUrlGenerator,
44+
private readonly SimpleTokenParser $simpleTokenParser,
45+
private readonly MailerInterface $mailer,
3846
private readonly TranslatorInterface $translator,
47+
#[Autowire(param: 'contao.registration.expiration')]
48+
private readonly int $registrationExpiration,
49+
#[Autowire(param: 'kernel.project_dir')]
50+
private readonly string $projectDir,
3951
private readonly ?LoggerInterface $logger = null,
4052
) {
4153
}
@@ -91,7 +103,7 @@ public function createMember(array $data, ContentModel $model, Request $request)
91103
}
92104

93105
if ($target = PageModel::findById($model->msrJumpTo ?? null)) {
94-
return new RedirectResponse(System::getContainer()->get('contao.routing.content_url_generator')->generate($target));
106+
return new RedirectResponse($this->contentUrlGenerator->generate($target));
95107
}
96108

97109
return null;
@@ -145,7 +157,7 @@ public function activateAccount(string $token, ContentModel $model): array
145157
$redirect = null;
146158

147159
if ($target = PageModel::findById($model->msrRegJumpTo ?? null)) {
148-
$redirect = new RedirectResponse(System::getContainer()->get('contao.routing.content_url_generator')->generate($target));
160+
$redirect = new RedirectResponse($this->contentUrlGenerator->generate($target));
149161
}
150162

151163
return [
@@ -160,18 +172,22 @@ public function activateAccount(string $token, ContentModel $model): array
160172
*/
161173
private function sendActivationMail(array $data, ContentModel $model, Request $request): void
162174
{
163-
$removeOn = new \DateTime('+'.System::getContainer()->getParameter('contao.registration.expiration').' days');
175+
$removeOn = new \DateTime('+'.$this->registrationExpiration.' days');
164176
$optInToken = $this->optIn->create('reg', (string) $data['email'], ['tl_member' => [$data['id']]]);
165177

166-
if ($optInModel = OptInModel::findByToken($optInToken->getIdentifier())) {
167-
$optInModel->removeOn = $removeOn->getTimestamp();
168-
$optInModel->save();
178+
if (!$optInToken instanceof OptInToken) {
179+
return;
169180
}
170181

171-
if (!$optInToken instanceof OptInToken) {
182+
$optInModel = OptInModel::findByToken($optInToken->getIdentifier());
183+
184+
if (null === $optInModel) {
172185
return;
173186
}
174187

188+
$optInModel->removeOn = $removeOn->getTimestamp();
189+
$optInModel->save();
190+
175191
$uri = $request->getUri();
176192

177193
$tokenData = $data;
@@ -190,8 +206,11 @@ private function sendActivationMail(array $data, ContentModel $model, Request $r
190206
$this->eventDispatcher->dispatch($event);
191207

192208
if ($event->shouldSendOptInToken()) {
193-
$text = System::getContainer()->get('contao.string.simple_token_parser')->parse($event->getText(), $event->getSimpleTokens());
194-
$optInToken->send($event->getSubject(), $text);
209+
$optInModel->emailSubject = $event->getSubject();
210+
$optInModel->emailText = $this->simpleTokenParser->parse($event->getText(), $event->getSimpleTokens());
211+
$optInModel->save();
212+
213+
$this->sendOptInMail($optInModel);
195214
}
196215
}
197216

@@ -207,9 +226,8 @@ private function assignHomeDirectory(MemberModel $member, array $data, ContentMo
207226
}
208227

209228
$userDir = StringUtil::standardize((string) ($data['username'] ?? '')) ?: 'user_'.$member->id;
210-
$projectDir = System::getContainer()->getParameter('kernel.project_dir');
211229

212-
while (is_dir($projectDir.'/'.$homeDir->path.'/'.$userDir)) {
230+
while (is_dir($this->projectDir.'/'.$homeDir->path.'/'.$userDir)) {
213231
$userDir .= '_'.$member->id;
214232
}
215233

@@ -238,13 +256,29 @@ private function resendActivationMail(MemberModel $member): void
238256
$token = $this->optIn->find($model->token);
239257

240258
if ($token && $token->isValid() && !$token->isConfirmed()) {
241-
$token->send();
259+
$this->sendOptInMail($model);
242260

243261
return;
244262
}
245263
}
246264
}
247265

266+
private function sendOptInMail(OptInModel $model): void
267+
{
268+
if (!$model->emailSubject || !$model->emailText) {
269+
throw new \LogicException('Please provide subject and text to send the token');
270+
}
271+
272+
$email = new Email()
273+
->from($this->getSender())
274+
->to((string) $model->email)
275+
->subject((string) $model->emailSubject)
276+
->html((string) $model->emailText)
277+
;
278+
279+
$this->mailer->send($email);
280+
}
281+
248282
private function createHookModule(ContentModel $model): Module
249283
{
250284
$moduleModel = new ModuleModel();
@@ -265,7 +299,9 @@ private function sendAdminNotification(int|string $id, array $data): void
265299
{
266300
$this->logger?->info('A new user (ID '.$id.') has registered on the website');
267301

268-
if (!isset($GLOBALS['TL_ADMIN_EMAIL'])) {
302+
$adminEmail = $GLOBALS['TL_ADMIN_EMAIL'] ?? null;
303+
304+
if (!\is_string($adminEmail) || '' === $adminEmail) {
269305
return;
270306
}
271307

@@ -285,12 +321,37 @@ private function sendAdminNotification(int|string $id, array $data): void
285321
$messageData .= ($GLOBALS['TL_LANG']['tl_member'][$key][0] ?? $key).': '.(\is_array($value) ? implode(', ', $value) : $value)."\n";
286322
}
287323

288-
$email = new Email();
289-
$email->from = $GLOBALS['TL_ADMIN_EMAIL'];
290-
$email->fromName = $GLOBALS['TL_ADMIN_NAME'] ?? null;
291-
$email->subject = \sprintf($GLOBALS['TL_LANG']['MSC']['adminSubject'], Idna::decode(Environment::get('host')));
292-
$email->text = \sprintf($GLOBALS['TL_LANG']['MSC']['adminText'], $id, $messageData."\n")."\n";
293-
$email->sendTo($GLOBALS['TL_ADMIN_EMAIL']);
324+
$email = new Email()
325+
->from($this->getSender())
326+
->to($adminEmail)
327+
->subject(\sprintf($GLOBALS['TL_LANG']['MSC']['adminSubject'], Idna::decode(Environment::get('host'))))
328+
->text(\sprintf($GLOBALS['TL_LANG']['MSC']['adminText'], $id, $messageData."\n")."\n")
329+
;
330+
331+
$this->mailer->send($email);
332+
}
333+
334+
private function getSender(): Address
335+
{
336+
$adminEmail = $GLOBALS['TL_ADMIN_EMAIL'] ?? null;
337+
338+
if (\is_string($adminEmail) && '' !== $adminEmail) {
339+
$adminName = $GLOBALS['TL_ADMIN_NAME'] ?? '';
340+
341+
return new Address($adminEmail, \is_string($adminName) ? $adminName : '');
342+
}
343+
344+
$adminEmail = Config::get('adminEmail');
345+
346+
if (\is_string($adminEmail) && '' !== $adminEmail) {
347+
[$name, $email] = StringUtil::splitFriendlyEmail($adminEmail);
348+
349+
if (\is_string($email) && '' !== $email) {
350+
return new Address($email, \is_string($name) ? $name : '');
351+
}
352+
}
353+
354+
throw new \LogicException('No administrator e-mail address has been set.');
294355
}
295356

296357
private function getModelValue(ContentModel $model, string $field): mixed

tests/Form/DcaFormFieldMapperTest.php

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
1111
use Symfony\Component\Form\Extension\Core\Type\EmailType;
1212
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
13+
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
14+
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
1315
use Symfony\Component\Validator\Constraints\Email;
1416
use Symfony\Component\Validator\Constraints\Length;
1517
use Symfony\Component\Validator\Constraints\NotBlank;
@@ -18,7 +20,7 @@ class DcaFormFieldMapperTest extends TestCase
1820
{
1921
public function testItMapsEmailFieldsAndConstraints(): void
2022
{
21-
$mapper = new DcaFormFieldMapper($this->createMock(Connection::class));
23+
$mapper = $this->createMapper();
2224
$dca = [
2325
'inputType' => 'text',
2426
'label' => ['Email', 'Your email address'],
@@ -31,14 +33,14 @@ public function testItMapsEmailFieldsAndConstraints(): void
3133
self::assertSame(EmailType::class, $type);
3234
self::assertSame('values[email]', $options['property_path']);
3335
self::assertContainsOnlyInstancesOf(NotBlank::class, [$constraints[0]]);
34-
self::assertContainsOnlyInstancesOf(Email::class, [$constraints[1]]);
35-
self::assertContainsOnlyInstancesOf(Length::class, [$constraints[2]]);
36+
self::assertContainsOnlyInstancesOf(Length::class, [$constraints[1]]);
37+
self::assertContainsOnlyInstancesOf(Email::class, [$constraints[2]]);
3638
self::assertSame(255, $options['attr']['maxlength']);
3739
}
3840

3941
public function testItMapsPasswordToRepeatedType(): void
4042
{
41-
$mapper = new DcaFormFieldMapper($this->createMock(Connection::class));
43+
$mapper = $this->createMapper();
4244
[$type, $options] = $mapper->mapField('password', [
4345
'inputType' => 'password',
4446
'label' => ['Password', ''],
@@ -51,7 +53,7 @@ public function testItMapsPasswordToRepeatedType(): void
5153

5254
public function testItPreservesAssociativeNumericChoiceKeys(): void
5355
{
54-
$mapper = new DcaFormFieldMapper($this->createMock(Connection::class));
56+
$mapper = $this->createMapper();
5557
[$type, $options] = $mapper->mapField('area', [
5658
'inputType' => 'select',
5759
'label' => ['Area', ''],
@@ -74,7 +76,7 @@ public function testItPreservesAssociativeNumericChoiceKeys(): void
7476

7577
public function testItMapsStringReferenceLabelsWithoutTruncatingThem(): void
7678
{
77-
$mapper = new DcaFormFieldMapper($this->createMock(Connection::class));
79+
$mapper = $this->createMapper();
7880
[$type, $options] = $mapper->mapField('type', [
7981
'inputType' => 'select',
8082
'label' => ['Member type', ''],
@@ -96,7 +98,7 @@ public function testItMapsStringReferenceLabelsWithoutTruncatingThem(): void
9698

9799
public function testItSerializesMultipleValuesWithoutCsv(): void
98100
{
99-
$mapper = new DcaFormFieldMapper($this->createMock(Connection::class));
101+
$mapper = $this->createMapper();
100102
$values = $mapper->normalizeSubmittedValues([
101103
'area' => [
102104
'inputType' => 'select',
@@ -108,4 +110,40 @@ public function testItSerializesMultipleValuesWithoutCsv(): void
108110

109111
self::assertSame(serialize(['22', '14', 'others']), $values['area']);
110112
}
113+
114+
public function testItHashesPasswordsWithTheInjectedHasherFactory(): void
115+
{
116+
$hasher = $this->createMock(PasswordHasherInterface::class);
117+
$hasher
118+
->expects(self::once())
119+
->method('hash')
120+
->with('secret')
121+
->willReturn('hashed-password')
122+
;
123+
124+
$hasherFactory = $this->createMock(PasswordHasherFactoryInterface::class);
125+
$hasherFactory
126+
->expects(self::once())
127+
->method('getPasswordHasher')
128+
->with(\Contao\FrontendUser::class)
129+
->willReturn($hasher)
130+
;
131+
132+
$mapper = new DcaFormFieldMapper($this->createMock(Connection::class), $hasherFactory);
133+
$values = $mapper->normalizeSubmittedValues([
134+
'password' => ['inputType' => 'password'],
135+
], [
136+
'password' => 'secret',
137+
]);
138+
139+
self::assertSame('hashed-password', $values['password']);
140+
}
141+
142+
private function createMapper(): DcaFormFieldMapper
143+
{
144+
return new DcaFormFieldMapper(
145+
$this->createMock(Connection::class),
146+
$this->createMock(PasswordHasherFactoryInterface::class),
147+
);
148+
}
111149
}

0 commit comments

Comments
 (0)