Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.24% covered (success)
95.24%
40 / 42
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
PasswordResetMail
95.24% covered (success)
95.24%
40 / 42
33.33% covered (danger)
33.33%
1 / 3
16
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
9
 send
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\User;
6
7use Celema\Quma\Database;
8use Celema\Verba\Verba;
9use Cosray\Config;
10use Cosray\Contract\Job;
11use Cosray\Exception\JobFailed;
12use Cosray\Locales;
13use Cosray\Mailer;
14use Cosray\Middleware\PanelLocale;
15use Cosray\Security\Policy;
16use Cosray\User;
17use Cosray\Users;
18use Cosray\Util\Transaction;
19use Override;
20use Psr\Log\LoggerInterface as Logger;
21use Symfony\Component\Mime\Address;
22
23/**
24 * Sends a password reset link for a requested login, if it belongs to a
25 * user with panel access. The request only queues this job, so it does the
26 * same work whether or not the login exists.
27 *
28 * @internal
29 */
30final class PasswordResetMail implements Job
31{
32    public function __construct(
33        private readonly PasswordResets $resets,
34        private readonly Users $users,
35        private readonly Policy $policy,
36        private readonly Mailer $mailer,
37        private readonly Locales $locales,
38        private readonly Config $config,
39        private readonly Database $db,
40        private readonly Logger $logger,
41    ) {}
42
43    #[Override]
44    public function handle(array $payload): void
45    {
46        $login = $payload['login'] ?? null;
47        $ip = $payload['ip'] ?? null;
48        $locale = $payload['locale'] ?? null;
49
50        if (!is_string($login) || !is_string($ip) || $locale !== null && !is_string($locale)) {
51            throw new JobFailed('A password reset request needs a login, an IP address and a locale or null.');
52        }
53
54        $user = $this->users->byLogin($login);
55
56        if ($user === null || !$this->policy->permits($user, 'panel')) {
57            return;
58        }
59
60        // A mail that cannot be queued takes its link along, so the retry is not refused as too soon.
61        $sent = Transaction::run($this->db, function () use ($user, $locale): bool {
62            $token = $this->resets->issue($user);
63
64            if ($token === null) {
65                return false;
66            }
67
68            $this->send($user, $token, $locale);
69
70            return true;
71        });
72
73        if ($sent) {
74            $this->logger->notice('Password reset link sent to {login} for a request from {ip}', [
75                'login' => $user->loginName(),
76                'ip' => $ip,
77            ]);
78        }
79    }
80
81    /** Sends the mail in the panel language the user chose, else in that of the request. */
82    private function send(User $user, #[\SensitiveParameter] string $token, ?string $requested): void
83    {
84        $available = $this->locales->panelLocales();
85        $locale = match (true) {
86            in_array($user->panelLocale, $available, true) => $user->panelLocale,
87            in_array($requested, $available, true) => $requested,
88            default => null,
89        };
90        $previous = Verba::translator();
91
92        if ($locale !== null) {
93            Verba::activate(new PanelLocale($this->config)->translator($this->locales, $locale));
94        }
95
96        try {
97            $this->mailer->send(
98                'cosray/password-reset',
99                to: new Address($user->email, $user->name ?? ''),
100                data: [
101                    'name' => $user->name ?? $user->loginName(),
102                    'site' => $this->config->app->title,
103                    'url' => $this->resets->url($token),
104                ],
105            );
106        } finally {
107            if ($previous !== null) {
108                Verba::activate($previous);
109            } else {
110                Verba::deactivate();
111            }
112        }
113    }
114}