Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.36% covered (success)
97.36%
221 / 227
89.47% covered (warning)
89.47%
17 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailFoldersHtmxController
97.35% covered (success)
97.35%
220 / 226
89.47% covered (warning)
89.47%
17 / 19
62
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
 foldersTree
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
12
 accountFolders
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
5
 folderModal
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 createFolder
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 reorderMailboxes
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 moveMailbox
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 reorderFolder
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
8
 nestFolder
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 renameFolder
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 deleteFolder
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 deleteFolderModal
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
2
 deleteFolderConfirm
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 mailboxInfoModal
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 unassignMailboxModal
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 unassignMailboxConfirm
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 flattenFolderTree
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
7.91
 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\Mail\Application\Service\WebmailAuthService;
12use App\Modules\Mail\Application\Service\WebmailFolderService;
13use App\Modules\Mail\Domain\Model\MailFolderDto;
14use App\Modules\Mail\Domain\Repository\MailRepositoryInterface;
15use Psr\Http\Message\ResponseFactoryInterface;
16use Psr\Http\Message\ResponseInterface;
17use Psr\Http\Message\ServerRequestInterface;
18use Throwable;
19use Twig\Environment as TwigEnvironment;
20use Yiisoft\User\CurrentUser;
21
22/**
23 * Handles Webmail HTMX interactions for folder management and trees.
24 *
25 * @package App\Modules\Mail\Presentation\Htmx
26 */
27final readonly class WebmailFoldersHtmxController
28{
29    private const string TEMPLATE_ALERT = 'mail/webmail/partials/alert.twig';
30
31    public function __construct(
32        private TwigEnvironment $twig,
33        private CurrentUser $currentUser,
34        private ResponseFactoryInterface $responseFactory,
35        private WebmailAuthService $authService,
36        private WebmailFolderService $folderService,
37        private MailRepositoryInterface $mailRepo,
38    ) {
39    }
40
41    /**
42     * Renders multi-account folder tree partial.
43     */
44    public function foldersTree(ServerRequestInterface $request, bool $preferCache = false): ResponseInterface
45    {
46        $userId = (int) $this->currentUser->getId();
47        $this->unlockSession();
48        $folderTree = $this->folderService->buildMultiAccountFolderTree($userId, $preferCache);
49
50        $params = $request->getQueryParams();
51        $activeMailboxId = isset($params['mailbox_id']) ? (int) $params['mailbox_id'] : null;
52        if ($activeMailboxId === null && isset($params['active_mailbox_id'])) {
53            $activeMailboxId = (int) $params['active_mailbox_id'];
54        }
55        if ($activeMailboxId === null && $folderTree !== []) {
56            $activeMailboxId = $folderTree[0]['mailbox']->id;
57        }
58        $activeFolder = trim((string) ($params['folder'] ?? ''));
59        if ($activeFolder === '') {
60            $activeFolder = 'INBOX';
61        }
62
63        $expandedMailboxIds = [];
64        if (!empty($params['expanded_ids'])) {
65            $rawIds = explode(',', (string) $params['expanded_ids']);
66            foreach ($rawIds as $rawId) {
67                $id = (int) trim($rawId);
68                if ($id > 0) {
69                    $expandedMailboxIds[] = $id;
70                }
71            }
72        }
73        if ($activeMailboxId !== null && !in_array($activeMailboxId, $expandedMailboxIds, true)) {
74            $expandedMailboxIds[] = $activeMailboxId;
75        }
76
77        $html = $this->twig->render('mail/webmail/partials/folders_tree.twig', [
78            'folder_tree'          => $folderTree,
79            'active_mailbox_id'    => $activeMailboxId,
80            'active_folder'        => $activeFolder,
81            'expanded_mailbox_ids' => $expandedMailboxIds,
82        ]);
83
84        return $this->htmlResponse($html);
85    }
86
87    /**
88     * Renders folder tree partial for a single mailbox account asynchronously.
89     */
90    public function accountFolders(ServerRequestInterface $request): ResponseInterface
91    {
92        $userId = (int) $this->currentUser->getId();
93        $this->unlockSession();
94        $params = $request->getQueryParams();
95        $mailboxId = (int) ($params['mailbox_id'] ?? 0);
96        $activeFolder = trim((string) ($params['folder'] ?? ''));
97        if ($activeFolder === '') {
98            $activeFolder = 'INBOX';
99        }
100
101        try {
102            $mailbox = $this->authService->getMailboxForUser($mailboxId, $userId);
103        } catch (Throwable) {
104            $html = $this->twig->render(self::TEMPLATE_ALERT, [
105                'type'    => 'danger',
106                'class'   => 'small px-3 py-2',
107                'message' => 'Mailbox not found or unauthorized.',
108            ]);
109            return $this->htmlResponse($html);
110        }
111
112        $sync = (string) ($params['sync'] ?? '');
113        $preferCache = ($sync !== '1' && $sync !== 'true');
114        $accountNode = $this->folderService->buildSingleAccountFolderTree($mailbox, $preferCache);
115
116        $activeMailboxId = isset($params['active_mailbox_id'])
117            ? (int) $params['active_mailbox_id']
118            : $mailboxId;
119
120        $html = $this->twig->render('mail/webmail/partials/account_folders.twig', [
121            'account'           => $accountNode,
122            'active_mailbox_id' => $activeMailboxId,
123            'active_folder'     => $activeFolder,
124        ]);
125
126        return $this->htmlResponse($html);
127    }
128
129    /**
130     * Renders folder creation / nesting / renaming modal.
131     */
132    public function folderModal(ServerRequestInterface $request): ResponseInterface
133    {
134        $userId = (int) $this->currentUser->getId();
135        $params = $request->getQueryParams();
136        $mailboxId = (int) ($params['mailbox_id'] ?? 0);
137        $folderPath = (string) ($params['folder'] ?? '');
138        $mode = (string) ($params['mode'] ?? 'create');
139
140        $folders = $this->folderService->getMailboxFolders($mailboxId, $userId);
141
142        $html = $this->twig->render('mail/webmail/partials/folder_modal.twig', [
143            'mailbox_id'  => $mailboxId,
144            'folder_path' => $folderPath,
145            'mode'        => $mode,
146            'folders'     => $folders,
147        ]);
148
149        return $this->htmlResponse($html);
150    }
151
152    /**
153     * Handles folder creation request.
154     */
155    public function createFolder(ServerRequestInterface $request): ResponseInterface
156    {
157        $userId = (int) $this->currentUser->getId();
158        $body = (array) $request->getParsedBody();
159        $mailboxId = (int) ($body['mailbox_id'] ?? 0);
160        $name = trim((string) ($body['name'] ?? ''));
161        $parent = isset($body['parent']) && $body['parent'] !== '' ? (string) $body['parent'] : null;
162
163        if ($name !== '') {
164            $this->folderService->createFolder($mailboxId, $userId, $name, $parent);
165        }
166
167        return $this->foldersTree($request);
168    }
169
170    /**
171     * Handles mailbox reordering via drag-and-drop.
172     */
173    public function reorderMailboxes(ServerRequestInterface $request): ResponseInterface
174    {
175        $userId = (int) $this->currentUser->getId();
176        $body = (array) $request->getParsedBody();
177        $rawIds = $body['mailbox_ids'] ?? $body['mailbox_ids[]'] ?? [];
178        if (is_string($rawIds)) {
179            $rawIds = explode(',', $rawIds);
180        }
181        $mailboxIds = array_map(static fn(mixed $id): int => (int) $id, (array) $rawIds);
182
183        if ($mailboxIds !== []) {
184            $this->authService->reorderMailboxes($userId, $mailboxIds);
185        }
186
187        return $this->foldersTree($request, true);
188    }
189
190    /**
191     * Handles shifting mailbox position up or down.
192     */
193    public function moveMailbox(ServerRequestInterface $request): ResponseInterface
194    {
195        $userId = (int) $this->currentUser->getId();
196        $params = array_merge((array) $request->getQueryParams(), (array) $request->getParsedBody());
197        $mailboxId = (int) ($params['mailbox_id'] ?? 0);
198        $direction = (string) ($params['direction'] ?? 'up');
199
200        if ($mailboxId > 0 && in_array($direction, ['up', 'down'], true)) {
201            $this->authService->moveMailbox($userId, $mailboxId, $direction);
202        }
203
204        return $this->foldersTree($request, true);
205    }
206
207    /**
208     * Handles folder reordering (move up or down, or relative drag-and-drop).
209     */
210    public function reorderFolder(ServerRequestInterface $request): ResponseInterface
211    {
212        $userId = (int) $this->currentUser->getId();
213        $body = (array) $request->getParsedBody();
214        $mailboxId = (int) ($body['mailbox_id'] ?? 0);
215        $folderPath = (string) ($body['folder_path'] ?? '');
216        $targetFolder = (string) ($body['target_folder'] ?? '');
217        $position = (string) ($body['position'] ?? '');
218        $direction = (string) ($body['direction'] ?? '');
219
220        $success = false;
221        if ($folderPath !== '') {
222            try {
223                if ($targetFolder !== '' && in_array($position, ['before', 'after', 'into'], true)) {
224                    $this->folderService->moveFolderRelative(
225                        $mailboxId,
226                        $userId,
227                        $folderPath,
228                        $targetFolder,
229                        $position
230                    );
231                } else {
232                    $dir = $direction !== '' ? $direction : 'up';
233                    $this->folderService->reorderFolder($mailboxId, $userId, $folderPath, $dir);
234                }
235                $success = true;
236            } catch (Throwable) {
237                // Gracefully fallback and re-render folder tree
238            }
239        }
240
241        $preferCache = $success && ($position !== 'into');
242        $response = $this->foldersTree($request, $preferCache);
243        $payload = $success
244            ? ['message' => 'Folder layout updated successfully.', 'type' => 'success']
245            : ['message' => 'Failed to move folder.', 'type' => 'danger'];
246
247        $trigger = json_encode(['webmailFolderReordered' => $payload], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
248
249        return $response->withHeader('HX-Trigger', $trigger);
250    }
251
252    /**
253     * Handles folder nesting / parent folder change.
254     */
255    public function nestFolder(ServerRequestInterface $request): ResponseInterface
256    {
257        $userId = (int) $this->currentUser->getId();
258        $body = (array) $request->getParsedBody();
259        $mailboxId = (int) ($body['mailbox_id'] ?? 0);
260        $folderPath = (string) ($body['folder_path'] ?? '');
261        $newParent = isset($body['new_parent']) && $body['new_parent'] !== ''
262            ? (string) $body['new_parent'] : null;
263
264        if ($folderPath !== '') {
265            $this->folderService->nestFolder($mailboxId, $userId, $folderPath, $newParent);
266        }
267
268        return $this->foldersTree($request);
269    }
270
271    /**
272     * Handles folder renaming.
273     */
274    public function renameFolder(ServerRequestInterface $request): ResponseInterface
275    {
276        $userId = (int) $this->currentUser->getId();
277        $body = (array) $request->getParsedBody();
278        $mailboxId = (int) ($body['mailbox_id'] ?? 0);
279        $oldPath = (string) ($body['old_path'] ?? '');
280        $newName = trim((string) ($body['new_name'] ?? ''));
281
282        if ($oldPath !== '' && $newName !== '') {
283            $this->folderService->renameFolder($mailboxId, $userId, $oldPath, $newName);
284        }
285
286        return $this->foldersTree($request);
287    }
288
289    /**
290     * Handles folder deletion.
291     */
292    public function deleteFolder(ServerRequestInterface $request): ResponseInterface
293    {
294        $userId = (int) $this->currentUser->getId();
295        $body = (array) $request->getParsedBody();
296        $mailboxId = (int) ($body['mailbox_id'] ?? 0);
297        $folderPath = (string) ($body['folder_path'] ?? '');
298
299        if ($folderPath !== '') {
300            $this->folderService->deleteFolder($mailboxId, $userId, $folderPath);
301        }
302
303        return $this->foldersTree($request);
304    }
305
306    /**
307     * Renders folder delete confirmation modal with target folder selection.
308     */
309    public function deleteFolderModal(ServerRequestInterface $request): ResponseInterface
310    {
311        $userId = (int) $this->currentUser->getId();
312        $params = $request->getQueryParams();
313        $mailboxId = (int) ($params['mailbox_id'] ?? 0);
314        $folderPath = (string) ($params['folder'] ?? '');
315
316        $mailbox = $this->authService->getMailboxForUser($mailboxId, $userId);
317        $tree = $this->folderService->getMailboxFolders($mailboxId, $userId);
318        $flat = $this->flattenFolderTree($tree);
319
320        // Filter out folder being deleted and its subfolders
321        $available = array_filter(
322            $flat,
323            static fn(MailFolderDto $f): bool =>
324                $f->path !== $folderPath && !str_starts_with($f->path, $folderPath . '/')
325        );
326
327        $html = $this->twig->render('mail/webmail/partials/folder_delete_modal.twig', [
328            'mailbox_id'        => $mailboxId,
329            'folder_path'       => $folderPath,
330            'folder_name'       => basename(str_replace('\\', '/', $folderPath)),
331            'default_target'    => $mailbox->folderTrash ?? 'Trash',
332            'available_folders' => array_values($available),
333        ]);
334
335        return $this->htmlResponse($html);
336    }
337
338    /**
339     * Handles folder deletion after migrating messages to destination folder.
340     */
341    public function deleteFolderConfirm(ServerRequestInterface $request): ResponseInterface
342    {
343        $userId = (int) $this->currentUser->getId();
344        $body = (array) $request->getParsedBody();
345        $mailboxId = (int) ($body['mailbox_id'] ?? 0);
346        $folderPath = (string) ($body['folder_path'] ?? '');
347        $targetFolder = (string) ($body['target_folder'] ?? 'Trash');
348
349        if ($folderPath !== '') {
350            $this->folderService->deleteFolderWithMigration($mailboxId, $userId, $folderPath, $targetFolder);
351        }
352
353        $response = $this->foldersTree($request);
354        return $response->withHeader('HX-Trigger', 'mailFoldersChanged');
355    }
356
357    /**
358     * Renders mailbox technical details, quota statistics, and protocol status modal.
359     */
360    public function mailboxInfoModal(ServerRequestInterface $request): ResponseInterface
361    {
362        $userId = (int) $this->currentUser->getId();
363        $params = $request->getQueryParams();
364        $mailboxId = (int) ($params['mailbox_id'] ?? 0);
365
366        $mailbox = $this->authService->getMailboxForUser($mailboxId, $userId);
367        $server = $this->mailRepo->findMailServerById($mailbox->mailServerId);
368        $quota = $this->folderService->getMailboxQuota($mailboxId, $userId);
369
370        $html = $this->twig->render('mail/webmail/partials/mailbox_info_modal.twig', [
371            'mailbox' => $mailbox,
372            'server'  => $server,
373            'quota'   => $quota,
374        ]);
375
376        return $this->htmlResponse($html);
377    }
378
379    /**
380     * Renders mailbox unassign confirmation modal.
381     */
382    public function unassignMailboxModal(ServerRequestInterface $request): ResponseInterface
383    {
384        $userId = (int) $this->currentUser->getId();
385        $params = $request->getQueryParams();
386        $mailboxId = (int) ($params['mailbox_id'] ?? 0);
387
388        try {
389            $mailbox = $this->authService->getMailboxForUser($mailboxId, $userId);
390        } catch (Throwable) {
391            return $this->htmlResponse('');
392        }
393
394        $html = $this->twig->render('mail/webmail/partials/mailbox_unassign_modal.twig', [
395            'mailbox' => $mailbox,
396        ]);
397
398        return $this->htmlResponse($html);
399    }
400
401    /**
402     * Handles unassigning mailbox from current user.
403     */
404    public function unassignMailboxConfirm(ServerRequestInterface $request): ResponseInterface
405    {
406        $userId = (int) $this->currentUser->getId();
407        $body = (array) $request->getParsedBody();
408        $mailboxId = (int) ($body['mailbox_id'] ?? 0);
409
410        if ($mailboxId > 0) {
411            $this->authService->unassignMailbox($mailboxId, $userId);
412        }
413
414        $response = $this->foldersTree($request, false);
415        $payload = [
416            'mailbox_id' => $mailboxId,
417            'message'    => 'Mailbox unassigned successfully.',
418            'type'       => 'success',
419        ];
420
421        $trigger = json_encode([
422            'webmailMailboxUnassigned' => $payload,
423            'mailFoldersChanged'       => true,
424        ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
425
426        return $response->withHeader('HX-Trigger', $trigger);
427    }
428
429    /**
430     * Recursively flattens hierarchical folder tree into array.
431     *
432     * @param array<int, MailFolderDto> $nodes
433     * @return array<int, MailFolderDto>
434     */
435    private function flattenFolderTree(array $nodes): array
436    {
437        $result = [];
438        foreach ($nodes as $node) {
439            $result[] = $node;
440            if ($node->hasChildren()) {
441                $children = $this->flattenFolderTree($node->children);
442                foreach ($children as $child) {
443                    $result[] = $child;
444                }
445            }
446        }
447        return $result;
448    }
449
450    private function htmlResponse(string $html, int $status = 200): ResponseInterface
451    {
452        $response = $this->responseFactory->createResponse($status)
453            ->withHeader('Content-Type', 'text/html; charset=utf-8');
454        $response->getBody()->write($html);
455        return $response;
456    }
457
458    private function unlockSession(): void
459    {
460        if (session_status() === PHP_SESSION_ACTIVE) {
461            session_write_close();
462        }
463    }
464}