Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
91.58% |
185 / 202 |
|
66.67% |
10 / 15 |
CRAP | |
0.00% |
0 / 1 |
| WebmailMessageDetailHtmxController | |
91.54% |
184 / 201 |
|
66.67% |
10 / 15 |
56.83 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| messagePreview | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
5 | |||
| messageSecurityCheck | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
7 | |||
| messageRelations | |
88.24% |
15 / 17 |
|
0.00% |
0 / 1 |
5.04 | |||
| messageRelationsDetect | |
83.33% |
15 / 18 |
|
0.00% |
0 / 1 |
5.12 | |||
| messageRelationsUnlink | |
84.21% |
16 / 19 |
|
0.00% |
0 / 1 |
5.10 | |||
| messageSource | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
6 | |||
| createEmlDownloadResponse | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| renderMessageSourceModalResponse | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| extractSenderIp | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| extractIpFromDirectHeaders | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
5.07 | |||
| extractIpFromReceivedHeaders | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
42 | |||
| extractUrls | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| htmlResponse | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| unlockSession | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Mail\Presentation\Htmx; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Modules\Integrations\Application\Service\EmailSecurityScanner; |
| 12 | use App\Modules\Mail\Application\Contract\EmailContextAssociationServiceInterface; |
| 13 | use App\Modules\Mail\Application\Service\WebmailMessageService; |
| 14 | use Psr\Http\Message\ResponseFactoryInterface; |
| 15 | use Psr\Http\Message\ResponseInterface; |
| 16 | use Psr\Http\Message\ServerRequestInterface; |
| 17 | use Throwable; |
| 18 | use Twig\Environment as TwigEnvironment; |
| 19 | use 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 | */ |
| 26 | final 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 | } |