Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.08% covered (success)
98.08%
306 / 312
88.89% covered (warning)
88.89%
32 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailHtmxController
98.07% covered (success)
98.07%
305 / 311
88.89% covered (warning)
88.89%
32 / 36
89
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
1
 foldersTree
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 accountFolders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 messagesList
100.00% covered (success)
100.00%
62 / 62
100.00% covered (success)
100.00%
1 / 1
10
 messagePreview
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 messageSecurityCheck
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 messageRelations
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 messageRelationsDetect
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 messageRelationsUnlink
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toggleFlag
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 composeModal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sendMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loginSubmit
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 settingsModal
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 saveSettings
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 folderModal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createFolder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reorderMailboxes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 moveMailbox
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reorderFolder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 nestFolder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renameFolder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deleteFolder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deleteFolderModal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deleteFolderConfirm
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mailboxInfoModal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unassignMailboxModal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unassignMailboxConfirm
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 messageAction
96.43% covered (success)
96.43%
81 / 84
0.00% covered (danger)
0.00%
0 / 1
19
 messageSource
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 htmlResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 resolveActionUids
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
7.07
 resolveBodyUids
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 executeMoveAction
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
 buildRefreshToast
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 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\WebmailAuthService;
14use App\Modules\Mail\Application\Service\WebmailFolderService;
15use App\Modules\Mail\Application\Service\WebmailMessageService;
16use App\Modules\Mail\Domain\Contract\WebmailSenderServiceInterface;
17use App\Modules\Mail\Domain\Model\MailSearchCriteriaDto;
18use App\Modules\Mail\Domain\Repository\MailRepositoryInterface;
19use App\Modules\Pdf\Application\Service\PdfGeneratorServiceInterface;
20use App\Modules\Pdf\Domain\Repository\PdfTemplateRepositoryInterface;
21use PDO;
22use Psr\Http\Message\ResponseFactoryInterface;
23use Psr\Http\Message\ResponseInterface;
24use Psr\Http\Message\ServerRequestInterface;
25use Throwable;
26use Twig\Environment as TwigEnvironment;
27use Yiisoft\User\CurrentUser;
28
29/**
30 * Webmail HTMX Partial Swapping Controller.
31 *
32 * Provides ultra-responsive dynamic HTML partials for folder navigation,
33 * real-time list filtering, isolated message viewing, and modal workflows without full-page reloads.
34 *
35 * @package App\Modules\Mail\Presentation\Htmx
36 */
37final readonly class WebmailHtmxController
38{
39    private const string FLAG_FLAGGED = '\\Flagged';
40    private const string TEMPLATE_ALERT = 'mail/webmail/partials/alert.twig';
41
42    private WebmailFoldersHtmxController $foldersController;
43    private WebmailComposeHtmxController $composeController;
44    private WebmailMessageDetailHtmxController $detailController;
45
46    /**
47     * WebmailHtmxController constructor.
48     */
49    public function __construct(
50        private TwigEnvironment $twig,
51        private CurrentUser $currentUser,
52        private ResponseFactoryInterface $responseFactory,
53        private WebmailAuthService $authService,
54        private WebmailFolderService $folderService,
55        private WebmailMessageService $messageService,
56        private WebmailSenderServiceInterface $senderService,
57        private MailRepositoryInterface $mailRepo,
58        private ?EmailSecurityScanner $securityScanner = null,
59        private ?EmailContextAssociationServiceInterface $associationService = null,
60        ?WebmailFoldersHtmxController $foldersController = null,
61        ?WebmailComposeHtmxController $composeController = null,
62        ?WebmailMessageDetailHtmxController $detailController = null,
63        private ?PdfGeneratorServiceInterface $pdfGenerator = null,
64        private ?PdfTemplateRepositoryInterface $pdfTemplateRepo = null,
65        private ?PDO $pdo = null,
66    ) {
67        $this->foldersController = $foldersController ?? new WebmailFoldersHtmxController(
68            $this->twig,
69            $this->currentUser,
70            $this->responseFactory,
71            $this->authService,
72            $this->folderService,
73            $this->mailRepo
74        );
75        $this->composeController = $composeController ?? new WebmailComposeHtmxController(
76            $this->twig,
77            $this->currentUser,
78            $this->responseFactory,
79            $this->authService,
80            $this->messageService,
81            $this->senderService,
82            $this->mailRepo,
83            $this->pdfGenerator,
84            $this->pdfTemplateRepo,
85            $this->pdo
86        );
87        $this->detailController = $detailController ?? new WebmailMessageDetailHtmxController(
88            $this->twig,
89            $this->currentUser,
90            $this->responseFactory,
91            $this->messageService,
92            $this->securityScanner,
93            $this->associationService
94        );
95    }
96
97    /**
98     * Renders multi-account folder tree partial.
99     */
100    public function foldersTree(ServerRequestInterface $request, bool $preferCache = false): ResponseInterface
101    {
102        return $this->foldersController->foldersTree($request, $preferCache);
103    }
104
105    /**
106     * Renders folder tree partial for a single mailbox account asynchronously.
107     */
108    public function accountFolders(ServerRequestInterface $request): ResponseInterface
109    {
110        return $this->foldersController->accountFolders($request);
111    }
112
113    /**
114     * Renders message list partial for a folder with pagination and filters.
115     */
116    public function messagesList(ServerRequestInterface $request): ResponseInterface
117    {
118        $userId = (int) $this->currentUser->getId();
119        $this->unlockSession();
120        $params = $request->getQueryParams();
121
122        $mailboxId = (int) ($params['mailbox_id'] ?? 0);
123        $folder    = trim((string) ($params['folder'] ?? ''));
124        if ($folder === '') {
125            $folder = 'INBOX';
126        }
127        $page      = max(1, (int) ($params['page'] ?? 1));
128        $filter    = (string) ($params['filter'] ?? 'all');
129        $search    = (string) ($params['q'] ?? '');
130
131        if ($mailboxId <= 0) {
132            $mailboxes = $this->authService->getUserMailboxes($userId);
133            if ($mailboxes === []) {
134                $html = $this->twig->render('mail/webmail/partials/empty_list.twig', [
135                    'message' => 'No connected mailboxes found.',
136                ]);
137                return $this->htmlResponse($html);
138            }
139            $mailboxId = $mailboxes[0]->id;
140        }
141
142        $criteria = new MailSearchCriteriaDto(
143            folder: $folder,
144            query: $search !== '' ? $search : null,
145            isUnreadOnly: $filter === 'unread',
146            isFlaggedOnly: $filter === 'flagged',
147            hasAttachmentsOnly: $filter === 'attachments',
148            sortDirection: (string) ($params['sort_dir'] ?? 'desc')
149        );
150
151        $error = null;
152        $messages = [];
153        $total = 0;
154        $isCached = false;
155
156        try {
157            $data = $this->messageService->fetchMessages($mailboxId, $userId, $criteria, $page, 25);
158            $messages = $data['messages'];
159            $total    = $data['total'];
160            $isCached = (bool) ($data['is_cached'] ?? false);
161        } catch (Throwable $e) {
162            $msg = $e->getMessage();
163            $isDecryptionError = str_contains($msg, 'Decryption failed')
164                || str_contains($msg, 'authentication tag mismatch');
165            $error = $isDecryptionError
166                ? 'Mailbox authentication failed: credentials could not be decrypted. Please re-authenticate.'
167                : $msg;
168        }
169
170        $isRefresh = ($params['refresh'] ?? '') === '1';
171        $prevTotal = isset($params['prev_total']) ? (int) $params['prev_total'] : null;
172
173        $html = $this->twig->render('mail/webmail/partials/messages_list.twig', [
174            'messages'   => $messages,
175            'total'      => $total,
176            'page'       => $page,
177            'mailbox_id' => $mailboxId,
178            'folder'     => $folder,
179            'filter'     => $filter,
180            'search'     => $search,
181            'error'      => $error,
182            'is_cached'  => $isCached,
183        ]);
184
185        $response = $this->htmlResponse($html);
186        if ($isRefresh) {
187            $toast = $this->buildRefreshToast($error, $total, $prevTotal);
188            $triggerHeader = json_encode([
189                'webmailRefreshResult' => $toast,
190            ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
191
192            return $response->withHeader('HX-Trigger', $triggerHeader);
193        }
194
195        return $response;
196    }
197
198    /**
199     * Renders message detail view partial for reading panel.
200     */
201    public function messagePreview(ServerRequestInterface $request): ResponseInterface
202    {
203        return $this->detailController->messagePreview($request);
204    }
205
206    /**
207     * Performs asynchronous security verification of an email (IP, Domain/SPF/DKIM, Links).
208     */
209    public function messageSecurityCheck(ServerRequestInterface $request): ResponseInterface
210    {
211        return $this->detailController->messageSecurityCheck($request);
212    }
213
214    /**
215     * Renders asynchronous CRM context associations bar.
216     */
217    public function messageRelations(ServerRequestInterface $request): ResponseInterface
218    {
219        return $this->detailController->messageRelations($request);
220    }
221
222    /**
223     * Forces re-detection of CRM associations from message headers and body.
224     */
225    public function messageRelationsDetect(ServerRequestInterface $request): ResponseInterface
226    {
227        return $this->detailController->messageRelationsDetect($request);
228    }
229
230    /**
231     * Unlinks specific CRM relation from message record.
232     */
233    public function messageRelationsUnlink(ServerRequestInterface $request): ResponseInterface
234    {
235        return $this->detailController->messageRelationsUnlink($request);
236    }
237
238    /**
239     * Toggles flag (\Flagged or \Seen) for a message.
240     */
241    public function toggleFlag(ServerRequestInterface $request): ResponseInterface
242    {
243        $userId = (int) $this->currentUser->getId();
244        $body   = (array) $request->getParsedBody();
245
246        $mailboxId = (int) ($body['mailbox_id'] ?? 0);
247        $folder    = (string) ($body['folder'] ?? 'INBOX');
248        $uid       = (string) ($body['uid'] ?? '');
249        $flag      = (string) ($body['flag'] ?? self::FLAG_FLAGGED);
250        $add       = (bool) ($body['add'] ?? true);
251
252        if ($mailboxId > 0 && $uid !== '') {
253            try {
254                $this->messageService->setFlags($mailboxId, $userId, $folder, [$uid], [$flag], $add);
255            } catch (Throwable) {
256                // Ignore flag toggle errors in partial
257            }
258        }
259
260        return $this->responseFactory->createResponse(204);
261    }
262
263    /**
264     * Renders floating compose drawer partial.
265     */
266    public function composeModal(ServerRequestInterface $request): ResponseInterface
267    {
268        return $this->composeController->composeModal($request);
269    }
270
271    /**
272     * Sends message drafted in compose drawer.
273     */
274    public function sendMessage(ServerRequestInterface $request): ResponseInterface
275    {
276        return $this->composeController->sendMessage($request);
277    }
278
279    /**
280     * Handles webmail login / connect form submission.
281     */
282    public function loginSubmit(ServerRequestInterface $request): ResponseInterface
283    {
284        $userId = (int) $this->currentUser->getId();
285        $body   = (array) $request->getParsedBody();
286
287        $email    = (string) ($body['email'] ?? '');
288        $password = (string) ($body['password'] ?? '');
289        $serverId = (int) ($body['mail_server_id'] ?? 0);
290        $remember = (bool) ($body['remember_credentials'] ?? true);
291
292        try {
293            $this->authService->connectAndLinkMailbox($userId, $email, $password, $serverId, $remember);
294            $response = $this->responseFactory->createResponse(200);
295            return $response->withHeader('HX-Redirect', '/mail');
296        } catch (Throwable $e) {
297            $html = $this->twig->render(self::TEMPLATE_ALERT, [
298                'type'    => 'danger',
299                'message' => $e->getMessage(),
300            ]);
301            return $this->htmlResponse($html, 400);
302        }
303    }
304
305    /**
306     * Renders mailbox settings modal with folder mappings and signature.
307     */
308    public function settingsModal(ServerRequestInterface $request): ResponseInterface
309    {
310        $userId = (int) $this->currentUser->getId();
311        $params = $request->getQueryParams();
312        $mailboxId = (int) ($params['mailbox_id'] ?? 0);
313
314        $mailboxes = $this->authService->getUserMailboxes($userId);
315        $targetMailbox = null;
316        foreach ($mailboxes as $mbox) {
317            if ($mbox->id === $mailboxId) {
318                $targetMailbox = $mbox;
319                break;
320            }
321        }
322
323        if ($targetMailbox === null) {
324            $html = $this->twig->render(self::TEMPLATE_ALERT, [
325                'type'    => 'danger',
326                'class'   => 'm-3',
327                'message' => 'Mailbox not found.',
328            ]);
329            return $this->htmlResponse($html, 404);
330        }
331
332        $folders = $this->folderService->getMailboxFolders($mailboxId, $userId);
333
334        $html = $this->twig->render('mail/webmail/partials/settings_modal.twig', [
335            'mailbox' => $targetMailbox,
336            'folders' => $folders,
337        ]);
338
339        return $this->htmlResponse($html);
340    }
341
342    /**
343     * Handles mailbox settings form submission.
344     */
345    public function saveSettings(ServerRequestInterface $request): ResponseInterface
346    {
347        $userId = (int) $this->currentUser->getId();
348        $body = (array) $request->getParsedBody();
349        $mailboxId = (int) ($body['mailbox_id'] ?? 0);
350
351        $success = $this->authService->updateMailboxSettings($mailboxId, $userId, $body);
352        if (!$success) {
353            $html = $this->twig->render(self::TEMPLATE_ALERT, [
354                'type'    => 'danger',
355                'class'   => 'py-2 px-3 small',
356                'message' => 'Failed to save mailbox settings.',
357            ]);
358            return $this->htmlResponse($html, 400);
359        }
360
361        $html = $this->twig->render(self::TEMPLATE_ALERT, [
362            'type'    => 'success',
363            'class'   => 'py-2 px-3 small',
364            'message' => 'Mailbox settings saved successfully!',
365        ]);
366        $response = $this->htmlResponse($html);
367        return $response->withHeader('HX-Trigger', 'mailFoldersChanged');
368    }
369
370    /**
371     * Renders folder creation / nesting / renaming modal.
372     */
373    public function folderModal(ServerRequestInterface $request): ResponseInterface
374    {
375        return $this->foldersController->folderModal($request);
376    }
377
378    /**
379     * Handles folder creation request.
380     */
381    public function createFolder(ServerRequestInterface $request): ResponseInterface
382    {
383        return $this->foldersController->createFolder($request);
384    }
385
386    /**
387     * Handles mailbox reordering via drag-and-drop.
388     */
389    public function reorderMailboxes(ServerRequestInterface $request): ResponseInterface
390    {
391        return $this->foldersController->reorderMailboxes($request);
392    }
393
394    /**
395     * Handles shifting mailbox position up or down.
396     */
397    public function moveMailbox(ServerRequestInterface $request): ResponseInterface
398    {
399        return $this->foldersController->moveMailbox($request);
400    }
401
402    /**
403     * Handles folder reordering (move up or down, or relative drag-and-drop).
404     */
405    public function reorderFolder(ServerRequestInterface $request): ResponseInterface
406    {
407        return $this->foldersController->reorderFolder($request);
408    }
409
410    /**
411     * Handles folder nesting / parent folder change.
412     */
413    public function nestFolder(ServerRequestInterface $request): ResponseInterface
414    {
415        return $this->foldersController->nestFolder($request);
416    }
417
418    /**
419     * Handles folder renaming.
420     */
421    public function renameFolder(ServerRequestInterface $request): ResponseInterface
422    {
423        return $this->foldersController->renameFolder($request);
424    }
425
426    /**
427     * Handles folder deletion.
428     */
429    public function deleteFolder(ServerRequestInterface $request): ResponseInterface
430    {
431        return $this->foldersController->deleteFolder($request);
432    }
433
434    /**
435     * Renders folder delete confirmation modal with target folder selection.
436     */
437    public function deleteFolderModal(ServerRequestInterface $request): ResponseInterface
438    {
439        return $this->foldersController->deleteFolderModal($request);
440    }
441
442    /**
443     * Handles folder deletion after migrating messages to destination folder.
444     */
445    public function deleteFolderConfirm(ServerRequestInterface $request): ResponseInterface
446    {
447        return $this->foldersController->deleteFolderConfirm($request);
448    }
449
450    /**
451     * Renders mailbox technical details, quota statistics, and protocol status modal.
452     */
453    public function mailboxInfoModal(ServerRequestInterface $request): ResponseInterface
454    {
455        return $this->foldersController->mailboxInfoModal($request);
456    }
457
458    /**
459     * Renders mailbox unassign confirmation modal.
460     */
461    public function unassignMailboxModal(ServerRequestInterface $request): ResponseInterface
462    {
463        return $this->foldersController->unassignMailboxModal($request);
464    }
465
466    /**
467     * Handles unassigning mailbox from current user.
468     */
469    public function unassignMailboxConfirm(ServerRequestInterface $request): ResponseInterface
470    {
471        return $this->foldersController->unassignMailboxConfirm($request);
472    }
473
474    /**
475     * Handles single or bulk message actions.
476     */
477    public function messageAction(ServerRequestInterface $request): ResponseInterface
478    {
479        $userId          = (int) $this->currentUser->getId();
480        $body            = (array) $request->getParsedBody();
481        $mailboxId       = (int) ($body['mailbox_id'] ?? 0);
482        $targetMailboxId = isset($body['target_mailbox_id']) ? (int) $body['target_mailbox_id'] : $mailboxId;
483        $folder          = (string) ($body['folder'] ?? 'INBOX');
484        $action          = (string) ($body['action'] ?? '');
485        $keyword         = (string) ($body['keyword'] ?? '$Important');
486        $targetFolder    = (string) ($body['target_folder'] ?? '');
487        $filter          = (string) ($body['filter'] ?? 'all');
488        $allScope        = !empty($body['all_scope']) || !empty($body['all_pages']);
489
490        $uids = $this->resolveActionUids($body, $allScope, $mailboxId, $userId, $folder, $filter);
491        if ($uids !== []) {
492            $mailbox = $this->authService->getMailboxForUser($mailboxId, $userId);
493            match ($action) {
494                'trash'    => $this->messageService->deleteMessages($mailboxId, $userId, $folder, $uids),
495                'spam'     => $this->messageService->moveMessages(
496                    $mailboxId,
497                    $userId,
498                    $folder,
499                    $mailbox->folderSpam ?? 'Spam',
500                    $uids
501                ),
502                'archive'  => $this->messageService->moveMessages(
503                    $mailboxId,
504                    $userId,
505                    $folder,
506                    $mailbox->folderArchive ?? 'Archive',
507                    $uids
508                ),
509                'move'     => $this->executeMoveAction(
510                    $targetFolder,
511                    $targetMailboxId,
512                    $mailboxId,
513                    $userId,
514                    $folder,
515                    $uids
516                ),
517                'read'     => $this->messageService->setFlags($mailboxId, $userId, $folder, $uids, ['\\Seen'], true),
518                'unread'   => $this->messageService->setFlags($mailboxId, $userId, $folder, $uids, ['\\Seen'], false),
519                'flag'     => $this->messageService->setFlags(
520                    $mailboxId,
521                    $userId,
522                    $folder,
523                    $uids,
524                    [self::FLAG_FLAGGED],
525                    true
526                ),
527                'unflag'   => $this->messageService->setFlags(
528                    $mailboxId,
529                    $userId,
530                    $folder,
531                    $uids,
532                    [self::FLAG_FLAGGED],
533                    false
534                ),
535                'label'    => $this->messageService->setFlags($mailboxId, $userId, $folder, $uids, [$keyword], true),
536                'unlabel'  => $this->messageService->setFlags($mailboxId, $userId, $folder, $uids, [$keyword], false),
537                default    => null,
538            };
539        }
540
541        $isDestructive = in_array($action, ['trash', 'spam', 'archive', 'move'], true);
542        if ($isDestructive) {
543            $html = $this->twig->render('mail/webmail/partials/empty_preview.twig', [
544                'message' => 'Select a message from the list to preview.',
545            ]);
546        } elseif (count($uids) === 1 && !empty($body['refresh_preview'])) {
547            $singleUid = reset($uids);
548            try {
549                $detail = $this->messageService->getMessageDetail(
550                    $mailboxId,
551                    $userId,
552                    $folder,
553                    $singleUid,
554                    false
555                );
556                $html = $this->twig->render('mail/webmail/partials/message_preview.twig', [
557                    'detail'     => $detail,
558                    'mailbox_id' => $mailboxId,
559                    'folder'     => $folder,
560                    'uid'        => $singleUid,
561                ]);
562            } catch (Throwable) {
563                $html = '';
564            }
565        } else {
566            $html = '';
567        }
568
569        $response = $this->htmlResponse($html);
570        return $response->withHeader('HX-Trigger', 'webmailMessageActionCompleted');
571    }
572
573    /**
574     * Renders raw message source modal or returns raw RFC822 EML download.
575     */
576    public function messageSource(ServerRequestInterface $request): ResponseInterface
577    {
578        return $this->detailController->messageSource($request);
579    }
580
581    private function htmlResponse(string $html, int $status = 200): ResponseInterface
582    {
583        $response = $this->responseFactory->createResponse($status)
584            ->withHeader('Content-Type', 'text/html; charset=utf-8');
585        $response->getBody()->write($html);
586        return $response;
587    }
588
589    /**
590     * @param array<string, mixed> $body
591     * @return array<string>
592     */
593    private function resolveActionUids(
594        array $body,
595        bool $allScope,
596        int $mailboxId,
597        int $userId,
598        string $folder,
599        string $filter
600    ): array {
601        if ($allScope && $mailboxId > 0) {
602            $criteria = match ($filter) {
603                'unread'      => new MailSearchCriteriaDto(folder: $folder, isUnreadOnly: true),
604                'flagged'     => new MailSearchCriteriaDto(folder: $folder, isFlaggedOnly: true),
605                'attachments' => new MailSearchCriteriaDto(folder: $folder, hasAttachmentsOnly: true),
606                default       => new MailSearchCriteriaDto(folder: $folder),
607            };
608            return $this->messageService->getAllFolderUids($mailboxId, $userId, $folder, $criteria);
609        }
610
611        return $this->resolveBodyUids($body);
612    }
613
614    /**
615     * @param array<string, mixed> $body
616     * @return array<string>
617     */
618    private function resolveBodyUids(array $body): array
619    {
620        if (isset($body['uids']) && is_array($body['uids'])) {
621            return array_map('strval', $body['uids']);
622        }
623
624        if (isset($body['uid']) && $body['uid'] !== '') {
625            return [(string) $body['uid']];
626        }
627
628        return [];
629    }
630
631    /**
632     * @param array<string> $uids
633     */
634    private function executeMoveAction(
635        string $targetFolder,
636        int $targetMailboxId,
637        int $mailboxId,
638        int $userId,
639        string $folder,
640        array $uids
641    ): void {
642        if ($targetFolder === '') {
643            return;
644        }
645
646        if ($targetMailboxId > 0 && $targetMailboxId !== $mailboxId) {
647            $this->messageService->moveMessagesCrossMailbox(
648                $mailboxId,
649                $targetMailboxId,
650                $userId,
651                $folder,
652                $targetFolder,
653                $uids
654            );
655            return;
656        }
657
658        $this->messageService->moveMessages($mailboxId, $userId, $folder, $targetFolder, $uids);
659    }
660
661    /**
662     * @return array{type: string, message: string}
663     */
664    private function buildRefreshToast(?string $error, int $total, ?int $prevTotal): array
665    {
666        if ($error !== null) {
667            return [
668                'type' => 'danger',
669                'message' => 'Failed to refresh mailbox: ' . $error,
670            ];
671        }
672
673        if ($prevTotal !== null && $total > $prevTotal) {
674            $diff = $total - $prevTotal;
675            $msg = $diff === 1 ? 'Fetched 1 new message.' : "Fetched {$diff} new messages.";
676
677            return [
678                'type' => 'success',
679                'message' => $msg,
680            ];
681        }
682
683        return [
684            'type' => 'info',
685            'message' => 'No new messages. Mailbox is up to date.',
686        ];
687    }
688
689    private function unlockSession(): void
690    {
691        if (session_status() === PHP_SESSION_ACTIVE) {
692            session_write_close();
693        }
694    }
695}