Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.58% covered (success)
91.58%
185 / 202
66.67% covered (warning)
66.67%
10 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailMessageDetailHtmxController
91.54% covered (success)
91.54%
184 / 201
66.67% covered (warning)
66.67%
10 / 15
56.83
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
 messagePreview
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
5
 messageSecurityCheck
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
7
 messageRelations
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
5.04
 messageRelationsDetect
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
5.12
 messageRelationsUnlink
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
5.10
 messageSource
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 createEmlDownloadResponse
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 renderMessageSourceModalResponse
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 extractSenderIp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 extractIpFromDirectHeaders
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 extractIpFromReceivedHeaders
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 extractUrls
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 htmlResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 unlockSession
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Mail\Presentation\Htmx;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Integrations\Application\Service\EmailSecurityScanner;
12use App\Modules\Mail\Application\Contract\EmailContextAssociationServiceInterface;
13use App\Modules\Mail\Application\Service\WebmailMessageService;
14use Psr\Http\Message\ResponseFactoryInterface;
15use Psr\Http\Message\ResponseInterface;
16use Psr\Http\Message\ServerRequestInterface;
17use Throwable;
18use Twig\Environment as TwigEnvironment;
19use Yiisoft\User\CurrentUser;
20
21/**
22 * Handles Webmail HTMX interactions for message preview, security analysis, and CRM relations.
23 *
24 * @package App\Modules\Mail\Presentation\Htmx
25 */
26final readonly class WebmailMessageDetailHtmxController
27{
28    private const string TEMPLATE_ALERT = 'mail/webmail/partials/alert.twig';
29    private const string TEMPLATE_RELATIONS_BAR = 'mail/webmail/partials/message_relations_bar.twig';
30
31    public function __construct(
32        private TwigEnvironment $twig,
33        private CurrentUser $currentUser,
34        private ResponseFactoryInterface $responseFactory,
35        private WebmailMessageService $messageService,
36        private ?EmailSecurityScanner $securityScanner = null,
37        private ?EmailContextAssociationServiceInterface $associationService = null
38    ) {
39    }
40
41    /**
42     * Renders message detail view partial for reading panel.
43     */
44    public function messagePreview(ServerRequestInterface $request): ResponseInterface
45    {
46        $userId = (int) $this->currentUser->getId();
47        $this->unlockSession();
48        $params = $request->getQueryParams();
49
50        $mailboxId   = (int) ($params['mailbox_id'] ?? 0);
51        $folder      = trim((string) ($params['folder'] ?? ''));
52        if ($folder === '') {
53            $folder = 'INBOX';
54        }
55        $uid         = (string) ($params['uid'] ?? '');
56        $allowImages = (bool) ($params['allow_images'] ?? false);
57
58        if ($mailboxId <= 0 || $uid === '') {
59            $html = $this->twig->render('mail/webmail/partials/empty_preview.twig', [
60                'message' => 'Select a message from the list.',
61            ]);
62            return $this->htmlResponse($html);
63        }
64
65        try {
66            $detail = $this->messageService->getMessageDetail(
67                $mailboxId,
68                $userId,
69                $folder,
70                $uid,
71                false,
72                $allowImages
73            );
74
75            $html = $this->twig->render('mail/webmail/partials/message_preview.twig', [
76                'detail'     => $detail,
77                'mailbox_id' => $mailboxId,
78                'folder'     => $folder,
79                'uid'        => $uid,
80            ]);
81
82            return $this->htmlResponse($html);
83        } catch (Throwable $e) {
84            $html = $this->twig->render(self::TEMPLATE_ALERT, [
85                'type'    => 'danger',
86                'class'   => 'm-3',
87                'message' => 'Failed to fetch message: ' . $e->getMessage(),
88            ]);
89            return $this->htmlResponse($html);
90        }
91    }
92
93    /**
94     * Performs asynchronous security verification of an email (IP, Domain/SPF/DKIM, Links).
95     */
96    public function messageSecurityCheck(ServerRequestInterface $request): ResponseInterface
97    {
98        $userId = (int) $this->currentUser->getId();
99        $params = $request->getQueryParams();
100
101        $mailboxId = (int) ($params['mailbox_id'] ?? 0);
102        $folder    = trim((string) ($params['folder'] ?? ''));
103        if ($folder === '') {
104            $folder = 'INBOX';
105        }
106        $uid = (string) ($params['uid'] ?? '');
107
108        if ($mailboxId <= 0 || $uid === '') {
109            return $this->htmlResponse('');
110        }
111
112        try {
113            $detail = $this->messageService->getMessageDetail(
114                $mailboxId,
115                $userId,
116                $folder,
117                $uid,
118                false,
119                false
120            );
121
122            $fromAddress = $detail->summary->fromEmail;
123            if (!empty($detail->summary->fromName) && $detail->summary->fromName !== $detail->summary->fromEmail) {
124                $fromAddress = sprintf('"%s" <%s>', $detail->summary->fromName, $detail->summary->fromEmail);
125            }
126
127            $senderIp = $this->extractSenderIp($detail->headers);
128            $urls = $this->extractUrls($detail->htmlBody, $detail->textBody);
129
130            $scanner = $this->securityScanner ?? new EmailSecurityScanner();
131            $security = $scanner->scanEmail($fromAddress, $senderIp, $urls, $detail->headers);
132
133            $html = $this->twig->render('mail/webmail/partials/security_banner.twig', array_merge($security, [
134                'mailbox_id' => $mailboxId,
135                'folder'     => $folder,
136                'uid'        => $uid,
137            ]));
138
139            return $this->htmlResponse($html);
140        } catch (Throwable $e) {
141            $msg = htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
142            $errorHtml = '<div class="alert alert-secondary py-1 px-2 mb-2 small text-muted">' .
143                '<i class="bi bi-shield me-1"></i>Verification skipped: ' . $msg .
144                '</div>';
145            return $this->htmlResponse($errorHtml);
146        }
147    }
148
149    /**
150     * Renders asynchronous CRM context associations bar (Company, Contact, Process, Subprocess).
151     */
152    public function messageRelations(ServerRequestInterface $request): ResponseInterface
153    {
154        $userId = (int) $this->currentUser->getId();
155        $params = $request->getQueryParams();
156
157        $mailboxId = (int) ($params['mailbox_id'] ?? 0);
158        $folder    = trim((string) ($params['folder'] ?? 'INBOX'));
159        $uid       = (string) ($params['uid'] ?? '');
160
161        if ($mailboxId <= 0 || $uid === '' || $this->associationService === null) {
162            return $this->htmlResponse('');
163        }
164
165        try {
166            $associations = $this->associationService->detectAndAssociate($mailboxId, $folder, $uid, $userId, false);
167            $html = $this->twig->render(self::TEMPLATE_RELATIONS_BAR, array_merge($associations, [
168                'mailbox_id' => $mailboxId,
169                'folder'     => $folder,
170                'uid'        => $uid,
171                'rechecked'  => false,
172            ]));
173
174            return $this->htmlResponse($html);
175        } catch (Throwable) {
176            return $this->htmlResponse('');
177        }
178    }
179
180    /**
181     * Forces re-detection of CRM associations from message headers and body.
182     */
183    public function messageRelationsDetect(ServerRequestInterface $request): ResponseInterface
184    {
185        $userId = (int) $this->currentUser->getId();
186        $parsedBody = (array) ($request->getParsedBody() ?? []);
187        $queryParams = $request->getQueryParams();
188
189        $mailboxId = (int) ($parsedBody['mailbox_id'] ?? $queryParams['mailbox_id'] ?? 0);
190        $folder    = trim((string) ($parsedBody['folder'] ?? $queryParams['folder'] ?? 'INBOX'));
191        $uid       = (string) ($parsedBody['uid'] ?? $queryParams['uid'] ?? '');
192
193        if ($mailboxId <= 0 || $uid === '' || $this->associationService === null) {
194            return $this->htmlResponse('');
195        }
196
197        try {
198            $associations = $this->associationService->detectAndAssociate($mailboxId, $folder, $uid, $userId, true);
199            $html = $this->twig->render(self::TEMPLATE_RELATIONS_BAR, array_merge($associations, [
200                'mailbox_id' => $mailboxId,
201                'folder'     => $folder,
202                'uid'        => $uid,
203                'rechecked'  => true,
204            ]));
205
206            return $this->htmlResponse($html);
207        } catch (Throwable) {
208            return $this->htmlResponse('');
209        }
210    }
211
212    /**
213     * Unlinks specific CRM relation from message record.
214     */
215    public function messageRelationsUnlink(ServerRequestInterface $request): ResponseInterface
216    {
217        $userId = (int) $this->currentUser->getId();
218        $parsedBody = (array) ($request->getParsedBody() ?? []);
219        $queryParams = $request->getQueryParams();
220
221        $mailboxId = (int) ($parsedBody['mailbox_id'] ?? $queryParams['mailbox_id'] ?? 0);
222        $folder    = trim((string) ($parsedBody['folder'] ?? $queryParams['folder'] ?? 'INBOX'));
223        $uid       = (string) ($parsedBody['uid'] ?? $queryParams['uid'] ?? '');
224        $type      = (string) ($parsedBody['type'] ?? $queryParams['type'] ?? 'all');
225
226        if ($mailboxId <= 0 || $uid === '' || $this->associationService === null) {
227            return $this->htmlResponse('');
228        }
229
230        try {
231            $associations = $this->associationService->unlinkRelation($mailboxId, $folder, $uid, $type, $userId);
232            $html = $this->twig->render(self::TEMPLATE_RELATIONS_BAR, array_merge($associations, [
233                'mailbox_id' => $mailboxId,
234                'folder'     => $folder,
235                'uid'        => $uid,
236                'rechecked'  => false,
237            ]));
238
239            return $this->htmlResponse($html);
240        } catch (Throwable) {
241            return $this->htmlResponse('');
242        }
243    }
244
245    /**
246     * Renders raw message source modal or returns raw RFC822 EML download.
247     */
248    public function messageSource(ServerRequestInterface $request): ResponseInterface
249    {
250        $userId = (int) $this->currentUser->getId();
251        $params = $request->getQueryParams();
252        $mailboxId = (int) ($params['mailbox_id'] ?? 0);
253        $folder = trim((string) ($params['folder'] ?? 'INBOX'));
254        $uid = trim((string) ($params['uid'] ?? ''));
255        $isDownload = isset($params['download']) && (string) $params['download'] === '1';
256
257        if ($mailboxId <= 0 || $uid === '') {
258            $html = $this->twig->render(self::TEMPLATE_ALERT, [
259                'type'    => 'danger',
260                'class'   => 'm-3',
261                'message' => 'Invalid message parameters.',
262            ]);
263            return $this->htmlResponse($html, 400);
264        }
265
266        try {
267            $source = $this->messageService->getRawMessageSource($mailboxId, $userId, $folder, $uid);
268
269            return $isDownload
270                ? $this->createEmlDownloadResponse($source, $uid)
271                : $this->renderMessageSourceModalResponse($source, $mailboxId, $folder, $uid);
272        } catch (Throwable $e) {
273            $html = $this->twig->render(self::TEMPLATE_ALERT, [
274                'type'    => 'danger',
275                'class'   => 'm-3',
276                'message' => 'Failed to retrieve message source: ' . $e->getMessage(),
277            ]);
278            return $this->htmlResponse($html, 500);
279        }
280    }
281
282    private function createEmlDownloadResponse(string $source, string $uid): ResponseInterface
283    {
284        $response = $this->responseFactory->createResponse(200)
285            ->withHeader('Content-Type', 'message/rfc822; charset=utf-8')
286            ->withHeader('Content-Disposition', 'attachment; filename="message-' . $uid . '.eml"')
287            ->withHeader('Content-Length', (string) strlen($source));
288        $response->getBody()->write($source);
289
290        return $response;
291    }
292
293    private function renderMessageSourceModalResponse(
294        string $source,
295        int $mailboxId,
296        string $folder,
297        string $uid
298    ): ResponseInterface {
299        $downloadUrl = '/htmx/mail/message/source?mailbox_id=' . $mailboxId
300            . '&folder=' . rawurlencode($folder)
301            . '&uid=' . rawurlencode($uid)
302            . '&download=1';
303
304        $html = $this->twig->render('mail/webmail/partials/modal_message_source.twig', [
305            'mailbox_id'   => $mailboxId,
306            'folder'       => $folder,
307            'uid'          => $uid,
308            'source'       => $source,
309            'size_bytes'   => strlen($source),
310            'download_url' => $downloadUrl,
311        ]);
312
313        return $this->htmlResponse($html);
314    }
315
316    /**
317     * Extracts sender mail server IP from RFC 822 email headers.
318     *
319     * @param array<string, mixed> $headers Message headers.
320     */
321    private function extractSenderIp(array $headers): ?string
322    {
323        return $this->extractIpFromDirectHeaders($headers)
324            ?? $this->extractIpFromReceivedHeaders($headers);
325    }
326
327    /**
328     * @param array<string, mixed> $headers
329     */
330    private function extractIpFromDirectHeaders(array $headers): ?string
331    {
332        $directHeaders = ['x-originating-ip', 'x-sender-ip', 'x-client-ip'];
333        foreach ($directHeaders as $header) {
334            $raw = (string) ($headers[$header] ?? '');
335            if ($raw !== ''
336                && preg_match('/\[?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\]?/', $raw, $m)
337                && filter_var($m[1], FILTER_VALIDATE_IP)) {
338                return $m[1];
339            }
340        }
341
342        return null;
343    }
344
345    /**
346     * @param array<string, mixed> $headers
347     */
348    private function extractIpFromReceivedHeaders(array $headers): ?string
349    {
350        if (empty($headers['received'])) {
351            return null;
352        }
353
354        $receivedList = is_array($headers['received']) ? $headers['received'] : [$headers['received']];
355        foreach ($receivedList as $received) {
356            if (preg_match('/\[(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\]/', (string) $received, $m)
357                && filter_var($m[1], FILTER_VALIDATE_IP)) {
358                return $m[1];
359            }
360        }
361
362        return null;
363    }
364
365    /**
366     * Extracts all unique HTTP/HTTPS URLs from HTML and plain text bodies.
367     *
368     * @return array<string> Unique URLs.
369     */
370    private function extractUrls(string $html, string $text): array
371    {
372        $urls = [];
373        $combined = $html . ' ' . $text;
374
375        if (preg_match_all('/https?:\/\/[^\s<>"\'\)]+/i', $combined, $matches)) {
376            foreach ($matches[0] as $url) {
377                $trimmed = rtrim($url, '.,;:!?');
378                if (filter_var($trimmed, FILTER_VALIDATE_URL)) {
379                    $urls[] = $trimmed;
380                }
381            }
382        }
383
384        return array_values(array_unique($urls));
385    }
386
387    private function htmlResponse(string $html, int $status = 200): ResponseInterface
388    {
389        $response = $this->responseFactory->createResponse($status)
390            ->withHeader('Content-Type', 'text/html; charset=utf-8');
391        $response->getBody()->write($html);
392        return $response;
393    }
394
395    private function unlockSession(): void
396    {
397        if (session_status() === PHP_SESSION_ACTIVE) {
398            session_write_close();
399        }
400    }
401}