Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.92% covered (success)
98.92%
183 / 185
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Notification
98.92% covered (success)
98.92%
183 / 185
77.78% covered (warning)
77.78%
7 / 9
31
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 sendOpenQuestionAnswered
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 sendNewFaqAdded
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
5
 sendFaqCommentNotification
98.08% covered (success)
98.08%
51 / 52
0.00% covered (danger)
0.00%
0 / 1
7
 sendNewsCommentNotification
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
2
 sendQuestionSuccessMail
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
8
 sendWebPushToUsers
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 createCategory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The notification class for phpMyFAQ.
5 * This Source Code Form is subject to the terms of the Mozilla Public License,
6 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
7 * obtain one at https://mozilla.org/MPL/2.0/.
8 *
9 * @package   phpMyFAQ
10 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
11 * @copyright 2012-2026 phpMyFAQ Team
12 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
13 * @link      https://www.phpmyfaq.de
14 * @since     2012-08-30
15 */
16
17declare(strict_types=1);
18
19namespace phpMyFAQ;
20
21use phpMyFAQ\Core\Exception;
22use phpMyFAQ\Entity\Comment;
23use phpMyFAQ\Entity\FaqEntity;
24use phpMyFAQ\Entity\QuestionEntity;
25use phpMyFAQ\Link\Util\TitleSlugifier;
26use phpMyFAQ\Push\WebPushService;
27use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
28
29/**
30 * Class Notification
31 *
32 * @package phpMyFAQ
33 */
34readonly class Notification
35{
36    private Mail $mail;
37
38    private Faq $faq;
39
40    private Category $category;
41
42    /**
43     * Constructor.
44     *
45     * @throws Core\Exception
46     */
47    public function __construct(
48        private Configuration $configuration,
49        private ?WebPushService $webPushService = null,
50        ?Mail $mail = null,
51        ?Faq $faq = null,
52        ?Category $category = null,
53    ) {
54        $this->mail = $mail ?? new Mail($this->configuration);
55        $this->faq = $faq ?? new Faq($this->configuration);
56        $this->category = $category ?? new Category($this->configuration);
57        $this->mail->setReplyTo($this->configuration->getNoReplyEmail(), $this->configuration->getTitle());
58    }
59
60    /**
61     * Sends mail to user who added a question.
62     *
63     * @param string $email Email address of the user
64     * @param string $userName Name of the user
65     * @param string $url URL of answered FAQ
66     * @throws Core\Exception|TransportExceptionInterface
67     */
68    public function sendOpenQuestionAnswered(string $email, string $userName, string $url): void
69    {
70        if ($this->configuration->get(item: 'main.enableNotifications')) {
71            $this->mail->addTo($email, $userName);
72            $this->mail->subject =
73                $this->configuration->getTitle() . ' - ' . Translation::getString(key: 'msgQuestionAnswered');
74            $this->mail->message = sprintf(
75                '%s' . "\n\r" . '%s',
76                sprintf(Translation::getString(key: 'msgMessageQuestionAnswered'), $this->configuration->getTitle()),
77                $url,
78            );
79            $this->mail->send();
80        }
81    }
82
83    /**
84     * Sends mails to FAQ admin and other given users about a newly added FAQ.
85     *
86     * @param array<string> $emails
87     * @throws Core\Exception|TransportExceptionInterface
88     */
89    public function sendNewFaqAdded(array $emails, FaqEntity $faqEntity): void
90    {
91        if ($this->configuration->get(item: 'main.enableNotifications')) {
92            $faqId = $faqEntity->getId();
93            if ($faqId === null) {
94                return;
95            }
96
97            $this->mail->addTo($this->configuration->getAdminEmail());
98            foreach ($emails as $email) {
99                if ($email === $this->configuration->getAdminEmail()) {
100                    continue;
101                }
102
103                $this->mail->addCc($email);
104            }
105
106            $this->mail->subject = $this->configuration->getTitle() . ': New FAQ was added.';
107            $this->faq->getFaq(faqId: $faqId, faqRevisionId: null, isAdmin: true);
108
109            $linkToAdmin = '%sadmin/faq/edit/%d/%s';
110            $url = sprintf($linkToAdmin, $this->configuration->getDefaultUrl(), $faqId, $faqEntity->getLanguage());
111            $link = new Link($url, $this->configuration);
112            $link->setTitle($this->faq->getQuestion($faqId));
113
114            $this->mail->message =
115                html_entity_decode(Translation::getString(key: 'msgMailCheck'))
116                . '<p><strong>'
117                . Translation::getString(key: 'msgAskYourQuestion')
118                . ':</strong> '
119                . $this->faq->getQuestion($faqId)
120                . '</p>'
121                . '<p><strong>'
122                . Translation::getString(key: 'msgNewContentArticle')
123                . ':</strong> '
124                . (string) ($this->faq->faqRecord['content'] ?? '')
125                . '</p>'
126                . '<hr>'
127                . $this->configuration->getTitle()
128                . ': <a target="_blank" href="'
129                . $link->toString()
130                . '">'
131                . $link->toString()
132                . '</a>';
133
134            $this->mail->contentType = 'text/html';
135
136            $this->mail->send();
137        }
138
139        // Note: Web push notification for new FAQs is sent from FaqController::create()
140        // with the public FAQ URL, which is more useful for end-users.
141    }
142
143    /**
144     * Sends mail to the user who added a comment.
145     *
146     * @throws TransportExceptionInterface
147     * @throws Exception
148     */
149    public function sendFaqCommentNotification(Faq $faq, Comment $comment): void
150    {
151        $category = $this->createCategory();
152        $emailTo = $this->configuration->getAdminEmail();
153
154        $recordEmail = (string) ($faq->faqRecord['email'] ?? '');
155        if ($recordEmail !== '') {
156            $emailTo = $recordEmail;
157        }
158
159        $title = (string) ($faq->faqRecord['title'] ?? '');
160        $faqId = (int) ($faq->faqRecord['id'] ?? 0);
161
162        $faqUrl = sprintf(
163            '%scontent/%d/%d/%s/%s.html',
164            $this->configuration->getDefaultUrl(),
165            $category->getCategoryIdFromFaq($faqId),
166            $faqId,
167            (string) ($faq->faqRecord['lang'] ?? ''),
168            TitleSlugifier::slug($title),
169        );
170        $link = new Link($faqUrl, $this->configuration);
171        $link->setTitle($title);
172
173        $urlToContent = $link->toHtmlAnchor();
174
175        $format = '%s: %s, <a href="mailto:%s">%s</a><br>%s: %s<br>%s: %s<br><br>%s:<br>%s';
176        $commentMail = sprintf(
177            $format,
178            Translation::getString(key: 'ad_stat_report_owner'),
179            $comment->getUsername(),
180            $comment->getEmail(),
181            $comment->getEmail(),
182            Translation::getString(key: 'msgQuestion'),
183            $title,
184            Translation::getString(key: 'ad_news_link_url'),
185            $urlToContent,
186            Translation::getString(key: 'msgYourComment'),
187            strip_tags(wordwrap($comment->getComment(), width: 72)),
188        );
189
190        $send = [];
191
192        $this->mail->setReplyTo($comment->getEmail(), $comment->getUsername());
193        $this->mail->addTo($emailTo);
194
195        $send[$emailTo] = 1;
196        $send[$this->configuration->getAdminEmail()] = 1;
197
198        // Let the category owner of a FAQ get a copy of the message
199        $category = $this->createCategory();
200        $categories = $category->getCategoryIdsFromFaq($faqId);
201        foreach ($categories as $_category) {
202            $userId = $category->getOwner((int) $_category);
203            $catUser = $this->createUser();
204            $catUser->getUserById($userId);
205            $catOwnerEmail = $catUser->getUserData(field: 'email');
206            if (!is_string($catOwnerEmail)) {
207                continue;
208            }
209
210            if ($catOwnerEmail !== '' && (!array_key_exists($catOwnerEmail, $send) && $catOwnerEmail !== $emailTo)) {
211                $this->mail->addCc($catOwnerEmail);
212                $send[$catOwnerEmail] = 1;
213            }
214        }
215
216        $this->mail->subject = $this->configuration->getTitle() . ': New comment for "' . $title . '"';
217        $this->mail->message = $commentMail;
218
219        $this->mail->send();
220    }
221
222    /**
223     * @throws Exception
224     * @throws TransportExceptionInterface
225     */
226    public function sendNewsCommentNotification(array $newsData, Comment $comment): void
227    {
228        $authorEmail = (string) ($newsData['authorEmail'] ?? '');
229        if ($authorEmail !== '') {
230            $this->mail->addTo($authorEmail);
231        }
232
233        $title = (string) ($newsData['header'] ?? '');
234
235        $newsUrl = sprintf(
236            '%snews/%d/%s/%s.html',
237            $this->configuration->getDefaultUrl(),
238            (int) ($newsData['id'] ?? 0),
239            (string) ($newsData['lang'] ?? ''),
240            TitleSlugifier::slug($title),
241        );
242        $link = new Link($newsUrl, $this->configuration);
243        $link->setTitle($title);
244
245        $urlToContent = $link->toString();
246
247        $format = '%s: %s, <a href="mailto:%s">%s</a><br>%s: %s<br>%s: %s<br><br>%s';
248        $commentMail = sprintf(
249            $format,
250            Translation::getString(key: 'ad_stat_report_owner'),
251            $comment->getUsername(),
252            $comment->getEmail(),
253            $comment->getEmail(),
254            Translation::getString(key: 'msgYourComment'),
255            $title,
256            Translation::getString(key: 'ad_news_link_url'),
257            $urlToContent,
258            strip_tags(wordwrap($comment->getComment(), width: 72)),
259        );
260
261        $this->mail->setReplyTo($comment->getEmail(), $comment->getUsername());
262
263        $send = [];
264        $send[$this->configuration->getAdminEmail()] = 1;
265
266        $this->mail->subject = $this->configuration->getTitle() . ': New comment for "' . $title . '"';
267        $this->mail->message = $commentMail;
268
269        $this->mail->send();
270    }
271
272    public function sendQuestionSuccessMail(QuestionEntity $questionEntity, array $categories): void
273    {
274        $mailText = '%s<br><br>User: %s, %s<br>%s: %s<br><br>%s: %s<br><br>%s';
275        $questionMail = sprintf(
276            $mailText,
277            Translation::getString(key: 'msgNewQuestionAdded'),
278            $questionEntity->getUsername(),
279            $questionEntity->getEmail(),
280            Translation::getString(key: 'msgCategory'),
281            (string) ($categories[$questionEntity->getCategoryId()]['name'] ?? ''),
282            Translation::getString(key: 'msgAskYourQuestion'),
283            wordwrap($questionEntity->getQuestion(), width: 72),
284            $this->configuration->getDefaultUrl() . 'admin/',
285        );
286
287        $userId = $this->category->getOwner($questionEntity->getCategoryId());
288        try {
289            $oUser = $this->createUser();
290            $oUser->getUserById($userId);
291            $userEmail = $oUser->getUserData(field: 'email');
292            if (!is_string($userEmail) || $userEmail === '') {
293                $userEmail = null;
294            }
295        } catch (Exception $exception) {
296            $this->configuration->getLogger()->error('Error getting user data: ' . $exception->getMessage());
297            $userEmail = null;
298        }
299
300        $mainAdminEmail = $this->configuration->getAdminEmail();
301
302        try {
303            $this->mail->setReplyTo($questionEntity->getEmail(), $questionEntity->getUsername());
304            $this->mail->addTo($mainAdminEmail);
305
306            // Let the category owner get a copy of the message
307            if ($userEmail !== null && $mainAdminEmail !== $userEmail) {
308                $this->mail->addCc($userEmail);
309            }
310
311            $this->mail->subject = $this->configuration->getTitle() . ': New Question was added.';
312            $this->mail->message = $questionMail;
313            $this->mail->send();
314        } catch (Exception|TransportExceptionInterface $exception) {
315            $this->configuration->getLogger()->error('Error sending mail: ' . $exception->getMessage());
316        }
317
318        // Send push notification only to admin and category owner (not all subscribers)
319        // since the URL points to the admin area
320        $adminUserIds = [];
321        if ($userId > 0) {
322            $adminUserIds[] = $userId;
323        }
324        // Add all superadmins
325        $superAdminIds = User::getSuperAdminIds($this->configuration);
326        $adminUserIds = array_unique(array_merge($adminUserIds, $superAdminIds));
327
328        $this->sendWebPushToUsers(
329            $adminUserIds,
330            Translation::getString(key: 'msgPushNewQuestion'),
331            mb_substr($questionEntity->getQuestion(), start: 0, length: 200),
332            $this->configuration->getDefaultUrl() . 'admin/',
333            'new-question',
334        );
335    }
336
337    /**
338     * Sends a web push notification to specific users.
339     *
340     * @param int[] $userIds
341     */
342    private function sendWebPushToUsers(
343        array $userIds,
344        string $title,
345        string $body,
346        string $url = '',
347        string $tag = '',
348    ): void {
349        if ($this->webPushService === null || !$this->webPushService->isEnabled()) {
350            return;
351        }
352
353        try {
354            $this->webPushService->sendToUsers($userIds, $title, $body, $url, $tag);
355        } catch (\Throwable $exception) {
356            $this->configuration->getLogger()->error('Web Push notification failed: ' . $exception->getMessage());
357        }
358    }
359
360    protected function createCategory(): Category
361    {
362        return new Category($this->configuration);
363    }
364
365    /**
366     * @throws Exception
367     */
368    protected function createUser(): User
369    {
370        return new User($this->configuration);
371    }
372}