Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.09% covered (warning)
72.09%
62 / 86
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContactService
72.09% covered (warning)
72.09%
62 / 86
25.00% covered (danger)
25.00%
1 / 4
26.85
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
 submit
77.27% covered (warning)
77.27%
17 / 22
0.00% covered (danger)
0.00%
0 / 1
6.42
 submitPublic
53.33% covered (warning)
53.33%
16 / 30
0.00% covered (danger)
0.00%
0 / 1
14.50
 dispatchAdminEmail
84.85% covered (warning)
84.85%
28 / 33
0.00% covered (danger)
0.00%
0 / 1
4.06
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Contact\Service;
6
7use App\Domain\Auth\Repository\AuthRepository;
8use App\Domain\Contact\Data\ContactMessageData;
9use App\Domain\Contact\Repository\ContactRepository;
10use App\Domain\Exception\ValidationException;
11use App\Domain\Notification\Data\EmailMessage;
12use App\Domain\Notification\Service\EmailServiceInterface;
13use Psr\Log\LoggerInterface;
14use Throwable;
15
16use function filter_var;
17use function htmlspecialchars;
18use function nl2br;
19use function sprintf;
20use function strlen;
21use function trim;
22
23final readonly class ContactService
24{
25    public const int MAX_SUBJECT_LENGTH = 255;
26    public const int MAX_MESSAGE_LENGTH = 10000;
27
28    /**
29     * @param list<string> $adminRecipients
30     * @param ContactRepository $contactRepository
31     * @param AuthRepository $authRepository
32     * @param EmailServiceInterface $emailService
33     * @param LoggerInterface $logger
34     */
35    public function __construct(
36        private ContactRepository $contactRepository,
37        private AuthRepository $authRepository,
38        private EmailServiceInterface $emailService,
39        private LoggerInterface $logger,
40        private array $adminRecipients,
41    ) {}
42
43    public function submit(int $userId, string $subject, string $message): ContactMessageData
44    {
45        $subject = trim($subject);
46        $message = trim($message);
47
48        if ($subject === '') {
49            throw new ValidationException('Subject is required');
50        }
51        if (strlen($subject) > self::MAX_SUBJECT_LENGTH) {
52            throw new ValidationException(sprintf(
53                'Subject must be %d characters or fewer',
54                self::MAX_SUBJECT_LENGTH,
55            ));
56        }
57        if ($message === '') {
58            throw new ValidationException('Message is required');
59        }
60        if (strlen($message) > self::MAX_MESSAGE_LENGTH) {
61            throw new ValidationException(sprintf(
62                'Message must be %d characters or fewer',
63                self::MAX_MESSAGE_LENGTH,
64            ));
65        }
66
67        $user = $this->authRepository->findUserById($userId);
68        if ($user === null) {
69            throw new ValidationException('User not found');
70        }
71
72        $stored = $this->contactRepository->insert($userId, $subject, $message);
73
74        $this->dispatchAdminEmail($subject, $message, $user->username, $user->email);
75
76        return $stored;
77    }
78
79    /**
80     * Stores a contact message from an unauthenticated (public) submitter.
81     * The caller is responsible for CAPTCHA verification before this runs.
82     * @param string $name
83     * @param string $email
84     * @param string $subject
85     * @param string $message
86     */
87    public function submitPublic(string $name, string $email, string $subject, string $message): ContactMessageData
88    {
89        $name = trim($name);
90        $email = trim($email);
91        $subject = trim($subject);
92        $message = trim($message);
93
94        if ($name === '') {
95            throw new ValidationException('Name is required');
96        }
97        if (strlen($name) > self::MAX_SUBJECT_LENGTH) {
98            throw new ValidationException(sprintf(
99                'Name must be %d characters or fewer',
100                self::MAX_SUBJECT_LENGTH,
101            ));
102        }
103        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
104            throw new ValidationException('A valid email address is required');
105        }
106        if ($subject === '') {
107            throw new ValidationException('Subject is required');
108        }
109        if (strlen($subject) > self::MAX_SUBJECT_LENGTH) {
110            throw new ValidationException(sprintf(
111                'Subject must be %d characters or fewer',
112                self::MAX_SUBJECT_LENGTH,
113            ));
114        }
115        if ($message === '') {
116            throw new ValidationException('Message is required');
117        }
118        if (strlen($message) > self::MAX_MESSAGE_LENGTH) {
119            throw new ValidationException(sprintf(
120                'Message must be %d characters or fewer',
121                self::MAX_MESSAGE_LENGTH,
122            ));
123        }
124
125        $stored = $this->contactRepository->insertPublic($name, $email, $subject, $message);
126
127        $this->dispatchAdminEmail($subject, $message, $name, $email);
128
129        return $stored;
130    }
131
132    private function dispatchAdminEmail(
133        string $subject,
134        string $message,
135        string $username,
136        string $userEmail,
137    ): void {
138        if ($this->adminRecipients === []) {
139            $this->logger->warning('Contact form submitted but no admin_recipients configured');
140            return;
141        }
142
143        $emailSubject = sprintf('[Contact] %s', $subject);
144        $bodyText = sprintf(
145            "From: %s <%s>\n\nSubject: %s\n\n%s",
146            $username,
147            $userEmail,
148            $subject,
149            $message,
150        );
151        $bodyHtml = sprintf(
152            '<p><strong>From:</strong> %s &lt;<a href="mailto:%s">%s</a>&gt;</p>'
153            . '<p><strong>Subject:</strong> %s</p>'
154            . '<hr><p>%s</p>',
155            htmlspecialchars($username),
156            htmlspecialchars($userEmail),
157            htmlspecialchars($userEmail),
158            htmlspecialchars($subject),
159            nl2br(htmlspecialchars($message)),
160        );
161
162        foreach ($this->adminRecipients as $recipient) {
163            try {
164                $this->emailService->send(new EmailMessage(
165                    to: $recipient,
166                    subject: $emailSubject,
167                    bodyHtml: $bodyHtml,
168                    bodyText: $bodyText,
169                ));
170            } catch (Throwable $e) {
171                $this->logger->error(
172                    'Failed to dispatch contact form email',
173                    ['recipient' => $recipient, 'error' => $e->getMessage()],
174                );
175            }
176        }
177    }
178}