Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.12% covered (success)
91.12%
277 / 304
53.57% covered (warning)
53.57%
15 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailFolderService
91.09% covered (success)
91.09%
276 / 303
53.57% covered (warning)
53.57%
15 / 28
97.99
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 buildMultiAccountFolderTree
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 buildSingleAccountFolderTree
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
9
 saveCachedAccountFolders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCachedAccountFolders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateSpecialFoldersMapping
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMailboxFolders
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 resolveCachedFoldersIfPreferred
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
12.41
 findUserMailbox
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 fetchLiveMailboxFolders
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
2.00
 buildFolderHierarchy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reorderFolder
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
4.07
 isSystemFolder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reorderSiblings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 moveFolderRelative
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 moveFolderInto
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 moveFolderAdjacent
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
6.02
 relocateSourceFolder
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 getFlatFolderList
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 applyNewOrder
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 createFolder
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 nestFolder
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
8.16
 renameFolder
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
8.16
 deleteFolder
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 deleteFolderWithMigration
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
4
 getMailboxQuota
95.56% covered (success)
95.56%
43 / 45
0.00% covered (danger)
0.00%
0 / 1
4
 updateFolderOrder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 connectDriver
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Security\Encryption\EncryptionException;
12use App\Core\Security\Encryption\EncryptionServiceInterface;
13use App\Modules\Mail\Application\Service\Connection\WebmailConnectionManager;
14use App\Modules\Mail\Application\Service\Connection\WebmailConnectionManagerInterface;
15use App\Modules\Mail\Application\Service\Folder\WebmailFolderCacheManager;
16use App\Modules\Mail\Application\Service\Folder\WebmailFolderOrganizer;
17use App\Modules\Mail\Application\Service\Folder\WebmailFolderTreeBuilder;
18use App\Modules\Mail\Domain\Contract\MailProtocolDriverInterface;
19use App\Modules\Mail\Domain\Contract\MailProtocolDriverRegistryInterface;
20use App\Modules\Mail\Domain\Exception\WebmailAuthException;
21use App\Modules\Mail\Domain\Exception\WebmailFolderException;
22use App\Modules\Mail\Domain\Model\ClientMailbox;
23use App\Modules\Mail\Domain\Model\MailFolderDto;
24use App\Modules\Mail\Domain\Model\MailMessageSummaryDto;
25use App\Modules\Mail\Domain\Model\MailSearchCriteriaDto;
26use App\Modules\Mail\Domain\Repository\MailRepositoryInterface;
27use PDO;
28use Throwable;
29use Yiisoft\Cache\CacheInterface;
30
31/**
32 * Webmail Multi-Account Folder Hierarchy and Mapping Service.
33 *
34 * Builds Outlook-style multi-mailbox tree structures, resolves unread counters,
35 * and maintains configurable special folder mappings (Sent, Trash, Spam, Drafts, Archive).
36 * Delegates specialized tasks to cache manager, tree builder, and folder organizer.
37 *
38 * @package App\Modules\Mail\Application\Service
39 */
40final readonly class WebmailFolderService
41{
42    private WebmailFolderCacheManager $cacheManager;
43    private WebmailFolderTreeBuilder $treeBuilder;
44    private WebmailFolderOrganizer $organizer;
45    private WebmailCircuitBreakerService $circuitBreaker;
46    private WebmailConnectionManagerInterface $connectionManager;
47
48    /**
49     * WebmailFolderService constructor.
50     *
51     * @param PDO                                     $pdo            Database connection.
52     * @param MailRepositoryInterface                 $mailRepo       Mail repository.
53     * @param WebmailAuthService                      $authService    Webmail auth service.
54     * @param EncryptionServiceInterface              $encryption     AES-256 encryption service.
55     * @param MailProtocolDriverRegistryInterface     $driverRegistry Protocol driver registry.
56     * @param CacheInterface|null                     $cache          Optional cache implementation.
57     * @param WebmailFolderCacheManager|null          $cacheManager   Optional folder cache manager.
58     * @param WebmailFolderTreeBuilder|null           $treeBuilder    Optional folder tree builder.
59     * @param WebmailFolderOrganizer|null             $organizer      Optional folder organizer.
60     * @param WebmailCircuitBreakerService|null       $circuitBreaker Optional circuit breaker service.
61     * @param WebmailConnectionManagerInterface|null  $connManager    Optional connection manager.
62     */
63    public function __construct(
64        private PDO $pdo,
65        private MailRepositoryInterface $mailRepo,
66        private WebmailAuthService $authService,
67        private EncryptionServiceInterface $encryption,
68        private MailProtocolDriverRegistryInterface $driverRegistry,
69        private ?CacheInterface $cache = null,
70        ?WebmailFolderCacheManager $cacheManager = null,
71        ?WebmailFolderTreeBuilder $treeBuilder = null,
72        ?WebmailFolderOrganizer $organizer = null,
73        ?WebmailCircuitBreakerService $circuitBreaker = null,
74        ?WebmailConnectionManagerInterface $connManager = null
75    ) {
76        $this->cacheManager = $cacheManager ?? new WebmailFolderCacheManager($this->pdo, $this->cache);
77        $this->treeBuilder = $treeBuilder ?? new WebmailFolderTreeBuilder();
78        $this->organizer = $organizer ?? new WebmailFolderOrganizer();
79        $this->circuitBreaker = $circuitBreaker ?? new WebmailCircuitBreakerService($this->cache);
80        $this->connectionManager = $connManager ?? new WebmailConnectionManager(
81            $this->mailRepo,
82            $this->encryption,
83            $this->driverRegistry
84        );
85    }
86
87    /**
88     * Constructs aggregated multi-account folder trees for user.
89     *
90     * @param int  $userId      User ID.
91     * @param bool $preferCache Whether to return cached folders if available.
92     * @return array<array{mailbox: ClientMailbox, folders: array<MailFolderDto>, error: ?string}> Multi-account tree.
93     */
94    public function buildMultiAccountFolderTree(int $userId, bool $preferCache = false): array
95    {
96        $mailboxes = $this->authService->getUserMailboxes($userId);
97        $tree = [];
98
99        foreach ($mailboxes as $mailbox) {
100            $tree[] = $this->buildSingleAccountFolderTree($mailbox, $preferCache);
101        }
102
103        return $tree;
104    }
105
106    /**
107     * Builds folder hierarchy tree for a single mailbox account.
108     *
109     * @param ClientMailbox $mailbox     Mailbox model.
110     * @param bool          $preferCache Whether to return cached folders if available.
111     * @return array{mailbox: ClientMailbox, folders: array<MailFolderDto>, error: ?string} Single account tree node.
112     */
113    public function buildSingleAccountFolderTree(ClientMailbox $mailbox, bool $preferCache = false): array
114    {
115        $cached = $this->cacheManager->getCachedAccountFolders($mailbox->id);
116
117        // Fast-path: return cached folders immediately if requested or if circuit breaker tripped
118        if ($cached !== null && $cached !== []) {
119            if ($preferCache || !$this->circuitBreaker->isAvailable($mailbox->id)) {
120                return [
121                    'mailbox' => $mailbox,
122                    'folders' => $cached,
123                    'error'   => $this->circuitBreaker->getLastError($mailbox->id),
124                ];
125            }
126        } elseif (!$this->circuitBreaker->isAvailable($mailbox->id)) {
127            return [
128                'mailbox' => $mailbox,
129                'folders' => [],
130                'error'   => $this->circuitBreaker->getLastError($mailbox->id) ?? 'Mailbox server is offline.',
131            ];
132        }
133
134        $folders = [];
135        $error   = null;
136
137        try {
138            $driver = $this->connectDriver($mailbox);
139            $rawFolders = $driver->listFolders();
140            $driver->disconnect();
141
142            $mappedFolders = $this->treeBuilder->applySpecialUseMappings($rawFolders, $mailbox);
143            $folders = $this->treeBuilder->buildFolderHierarchy($mappedFolders, $mailbox->folderOrder);
144
145            $this->cacheManager->saveCachedAccountFolders($mailbox->id, $folders);
146            $this->circuitBreaker->recordSuccess($mailbox->id);
147        } catch (Throwable $e) {
148            $error = $e->getMessage();
149            $this->circuitBreaker->recordFailure($mailbox->id, $error);
150            if ($cached !== null && $cached !== []) {
151                $folders = $cached;
152            }
153        }
154
155        return [
156            'mailbox' => $mailbox,
157            'folders' => $folders,
158            'error'   => $error,
159        ];
160    }
161
162    /**
163     * Saves folder tree hierarchy into database row and cache backend.
164     *
165     * @param int                  $mailboxId Mailbox primary key.
166     * @param array<MailFolderDto> $folders   Folder hierarchy.
167     */
168    public function saveCachedAccountFolders(int $mailboxId, array $folders): void
169    {
170        $this->cacheManager->saveCachedAccountFolders($mailboxId, $folders);
171    }
172
173    /**
174     * Retrieves cached folder hierarchy from memory cache or database.
175     *
176     * @param int $mailboxId Mailbox primary key.
177     * @return array<MailFolderDto>|null Cached folder tree or null if none.
178     */
179    public function getCachedAccountFolders(int $mailboxId): ?array
180    {
181        return $this->cacheManager->getCachedAccountFolders($mailboxId);
182    }
183
184    /**
185     * Updates configurable special folders mapping for a specific mailbox.
186     *
187     * @param int                    $mailboxId Mailbox primary key.
188     * @param array<string, ?string> $mapping   Folder mappings ['sent' => '...', 'trash' => '...'].
189     */
190    public function updateSpecialFoldersMapping(int $mailboxId, array $mapping): void
191    {
192        $this->cacheManager->updateSpecialFoldersMapping($mailboxId, $mapping);
193    }
194
195    /**
196     * Retrieves folder list for a single specific mailbox.
197     *
198     * @param int  $mailboxId   Mailbox primary key.
199     * @param int  $userId      Authenticated user ID.
200     * @param bool $preferCache Whether to return cached folders if available.
201     * @return array<MailFolderDto> List of folders.
202     */
203    public function getMailboxFolders(int $mailboxId, int $userId, bool $preferCache = false): array
204    {
205        $cachedResult = $this->resolveCachedFoldersIfPreferred($mailboxId, $preferCache);
206        if ($cachedResult !== null) {
207            return $cachedResult;
208        }
209
210        $target = $this->findUserMailbox($userId, $mailboxId);
211        if ($target === null) {
212            return [];
213        }
214
215        return $this->fetchLiveMailboxFolders($target, $mailboxId);
216    }
217
218    /**
219     * @return array<MailFolderDto>|null
220     */
221    private function resolveCachedFoldersIfPreferred(int $mailboxId, bool $preferCache): ?array
222    {
223        if (!$preferCache) {
224            return null;
225        }
226
227        $cached = $this->cacheManager->getCachedAccountFolders($mailboxId);
228        if ($cached !== null && $cached !== []) {
229            return $cached;
230        }
231
232        return !$this->circuitBreaker->isAvailable($mailboxId) ? [] : null;
233    }
234
235    private function findUserMailbox(int $userId, int $mailboxId): ?ClientMailbox
236    {
237        $mailboxes = $this->authService->getUserMailboxes($userId);
238        foreach ($mailboxes as $mbox) {
239            if ($mbox->id === $mailboxId) {
240                return $mbox;
241            }
242        }
243
244        return null;
245    }
246
247    /**
248     * @return array<MailFolderDto>
249     */
250    private function fetchLiveMailboxFolders(ClientMailbox $target, int $mailboxId): array
251    {
252        try {
253            $server = $this->mailRepo->findMailServerById($target->mailServerId);
254            $driver = $this->driverRegistry->getDriver($target->protocolType);
255            $plainPass = $this->encryption->decrypt($target->password);
256
257            $activeMailbox = new ClientMailbox(
258                id: $target->id,
259                name: $target->name,
260                email: $target->email,
261                fromName: $target->fromName,
262                mailServerId: $target->mailServerId,
263                protocolType: $target->protocolType,
264                username: $target->username,
265                password: $plainPass,
266                folderInbox: $target->folderInbox,
267                folderSent: $target->folderSent,
268                folderDrafts: $target->folderDrafts,
269                folderTrash: $target->folderTrash,
270                folderSpam: $target->folderSpam,
271                folderArchive: $target->folderArchive,
272                signatureHtml: $target->signatureHtml,
273                folderOrder: $target->folderOrder
274            );
275
276            $driver->connect($activeMailbox, $server);
277            $rawFolders = $driver->listFolders();
278            $driver->disconnect();
279
280            $mapped = $this->treeBuilder->applySpecialUseMappings($rawFolders, $target);
281            $result = $this->treeBuilder->buildFolderHierarchy($mapped, $target->folderOrder);
282            $this->cacheManager->saveCachedAccountFolders($mailboxId, $result);
283            $this->circuitBreaker->recordSuccess($mailboxId);
284
285            return $result;
286        } catch (Throwable $e) {
287            $this->circuitBreaker->recordFailure($mailboxId, $e->getMessage());
288            return $this->cacheManager->getCachedAccountFolders($mailboxId) ?? [];
289        }
290    }
291
292    /**
293     * Constructs recursive folder hierarchy and sorts sibling nodes by custom order.
294     *
295     * @param array<MailFolderDto> $flatFolders Flat list of folders.
296     * @param array<string>|null   $orderConfig Saved folder order array.
297     * @return array<MailFolderDto> Nested and sorted tree nodes.
298     */
299    public function buildFolderHierarchy(array $flatFolders, ?array $orderConfig = null): array
300    {
301        return $this->treeBuilder->buildFolderHierarchy($flatFolders, $orderConfig);
302    }
303
304    /**
305     * Reorders a folder within its sibling group in the specified direction.
306     *
307     * @param int    $mailboxId  Mailbox ID.
308     * @param int    $userId     User ID.
309     * @param string $folderPath Folder path to move.
310     * @param string $direction  'up' or 'down'.
311     */
312    public function reorderFolder(int $mailboxId, int $userId, string $folderPath, string $direction): void
313    {
314        $mailbox = $this->authService->getMailboxForUser($mailboxId, $userId);
315        $flatList = $this->getFlatFolderList($mailboxId, $userId);
316
317        $targetFolder = $this->organizer->findFolderByPath($flatList, $folderPath);
318        if ($targetFolder === null) {
319            return;
320        }
321
322        $delim = $targetFolder->delimiter !== '' ? $targetFolder->delimiter : '/';
323        $parentPath = $this->organizer->getParentPath($folderPath, $delim);
324        $siblings = $this->organizer->getFolderSiblings($flatList, $parentPath);
325
326        $swapped = $this->organizer->swapSiblingPosition($siblings, $folderPath, $direction);
327        if ($swapped === null) {
328            return;
329        }
330
331        $this->applyNewOrder($mailboxId, $mailbox, $flatList, $swapped);
332    }
333
334    /**
335     * Checks if a folder is a standard system folder that cannot be deleted or nested.
336     *
337     * @param ClientMailbox $mailbox    Mailbox configuration.
338     * @param string        $folderPath Folder path.
339     * @return bool True if system folder.
340     */
341    public function isSystemFolder(ClientMailbox $mailbox, string $folderPath): bool
342    {
343        return $this->organizer->isSystemFolder($mailbox, $folderPath);
344    }
345
346    /**
347     * Slices and reorders siblings list by placing source before or after target.
348     *
349     * @param array<string> $siblings   List of sibling folder paths.
350     * @param string        $sourcePath Path to move.
351     * @param string        $targetPath Reference path.
352     * @param string        $position   'before' or 'after'.
353     * @return array<string> Reordered siblings.
354     */
355    public function reorderSiblings(
356        array $siblings,
357        string $sourcePath,
358        string $targetPath,
359        string $position
360    ): array {
361        return $this->organizer->reorderSiblings($siblings, $sourcePath, $targetPath, $position);
362    }
363
364    /**
365     * Moves a folder relative to another folder (before, after, or nested into).
366     *
367     * @param int    $mailboxId  Mailbox ID.
368     * @param int    $userId     User ID.
369     * @param string $sourcePath Source folder path.
370     * @param string $targetPath Target folder path.
371     * @param string $position   'before', 'after', or 'into'.
372     */
373    public function moveFolderRelative(
374        int $mailboxId,
375        int $userId,
376        string $sourcePath,
377        string $targetPath,
378        string $position
379    ): void {
380        if ($sourcePath === '' || $targetPath === '' || $sourcePath === $targetPath) {
381            return;
382        }
383
384        $mailbox = $this->authService->getMailboxForUser($mailboxId, $userId);
385
386        if ($position === 'into') {
387            $this->moveFolderInto($mailboxId, $userId, $mailbox, $sourcePath, $targetPath);
388            return;
389        }
390
391        if ($position === 'before' || $position === 'after') {
392            $this->moveFolderAdjacent($mailboxId, $userId, $mailbox, $sourcePath, $targetPath, $position);
393        }
394    }
395
396    /**
397     * Moves folder nested inside another target folder.
398     */
399    private function moveFolderInto(
400        int $mailboxId,
401        int $userId,
402        ClientMailbox $mailbox,
403        string $sourcePath,
404        string $targetPath
405    ): void {
406        if ($this->organizer->isSystemFolder($mailbox, $sourcePath)
407            || str_starts_with($targetPath, $sourcePath . '/')) {
408            return;
409        }
410        $this->nestFolder($mailboxId, $userId, $sourcePath, $targetPath);
411    }
412
413    /**
414     * Moves folder adjacent to a sibling target folder.
415     */
416    private function moveFolderAdjacent(
417        int $mailboxId,
418        int $userId,
419        ClientMailbox $mailbox,
420        string $sourcePath,
421        string $targetPath,
422        string $position
423    ): void {
424        $flatList = $this->getFlatFolderList($mailboxId, $userId);
425        $targetFolder = $this->organizer->findFolderByPath($flatList, $targetPath);
426        $sourceFolder = $this->organizer->findFolderByPath($flatList, $sourcePath);
427        if ($targetFolder === null || $sourceFolder === null) {
428            return;
429        }
430
431        $delim = $targetFolder->delimiter !== '' ? $targetFolder->delimiter : '/';
432        $targetParent = $this->organizer->getParentPath($targetPath, $delim);
433        $sourceParent = $this->organizer->getParentPath($sourcePath, $delim);
434
435        $effectiveSourcePath = $sourcePath;
436        if ($sourceParent !== $targetParent) {
437            $effectiveSourcePath = $this->relocateSourceFolder(
438                $mailboxId,
439                $userId,
440                $mailbox,
441                $sourcePath,
442                $targetParent,
443                $delim
444            );
445            if ($effectiveSourcePath === null) {
446                return;
447            }
448            $flatList = $this->treeBuilder->flattenFolderPaths($this->getMailboxFolders($mailboxId, $userId));
449        }
450
451        $siblings = $this->organizer->getFolderSiblings($flatList, $targetParent);
452        $newSiblings = $this->organizer->reorderSiblings($siblings, $effectiveSourcePath, $targetPath, $position);
453
454        $this->applyNewOrder($mailboxId, $mailbox, $flatList, $newSiblings);
455    }
456
457    /**
458     * Relocates source folder to new parent path during cross-parent moves.
459     */
460    private function relocateSourceFolder(
461        int $mailboxId,
462        int $userId,
463        ClientMailbox $mailbox,
464        string $sourcePath,
465        ?string $targetParent,
466        string $delim
467    ): ?string {
468        if ($this->organizer->isSystemFolder($mailbox, $sourcePath)) {
469            return null;
470        }
471        if ($targetParent !== null && str_starts_with($targetParent, $sourcePath . $delim)) {
472            return null;
473        }
474
475        $this->nestFolder($mailboxId, $userId, $sourcePath, $targetParent);
476        $baseName = basename(str_replace('\\', '/', $sourcePath));
477
478        return ($targetParent !== null && $targetParent !== '')
479            ? sprintf('%s%s%s', rtrim($targetParent, $delim), $delim, $baseName)
480            : $baseName;
481    }
482
483    /**
484     * Retrieves flat folder list for mailbox.
485     *
486     * @return array<MailFolderDto>
487     */
488    private function getFlatFolderList(int $mailboxId, int $userId): array
489    {
490        $rawFolders = $this->cacheManager->getCachedAccountFolders($mailboxId);
491        if ($rawFolders === null || $rawFolders === []) {
492            $rawFolders = $this->getMailboxFolders($mailboxId, $userId);
493        }
494        return $this->treeBuilder->flattenFolderPaths($rawFolders);
495    }
496
497    /**
498     * Applies new sibling order to mailbox and cache.
499     *
500     * @param array<MailFolderDto> $flatList
501     * @param array<string> $newSiblings
502     */
503    private function applyNewOrder(
504        int $mailboxId,
505        ClientMailbox $mailbox,
506        array $flatList,
507        array $newSiblings
508    ): void {
509        $existingOrder = $mailbox->folderOrder ?? [];
510        $remaining = array_values(array_diff($existingOrder, $newSiblings));
511        $newOrder = array_merge($remaining, $newSiblings);
512
513        $this->cacheManager->updateFolderOrder($mailboxId, $newOrder);
514        $reorderedTree = $this->treeBuilder->buildFolderHierarchy($flatList, $newOrder);
515        $this->cacheManager->saveCachedAccountFolders($mailboxId, $reorderedTree);
516    }
517
518    /**
519     * Creates new folder or nested subfolder in the mailbox.
520     */
521    public function createFolder(
522        int $mailboxId,
523        int $userId,
524        string $folderName,
525        ?string $parentFolder = null
526    ): void {
527        $mailbox = $this->authService->getMailboxForUser($mailboxId, $userId);
528        $driver = $this->connectDriver($mailbox);
529        $created = $driver->createFolder($folderName, $parentFolder);
530        $driver->disconnect();
531
532        $order = $mailbox->folderOrder ?? [];
533        $order[] = $created->path;
534        $this->cacheManager->updateFolderOrder($mailboxId, $order);
535        try {
536            $rawFolders = $this->getMailboxFolders($mailboxId, $userId);
537            $this->cacheManager->saveCachedAccountFolders($mailboxId, $rawFolders);
538        } catch (Throwable) {
539            // Ignore cache refresh error
540        }
541    }
542
543    /**
544     * Moves a folder under a new parent or to the root level.
545     */
546    public function nestFolder(
547        int $mailboxId,
548        int $userId,
549        string $folderPath,
550        ?string $newParentFolder
551    ): void {
552        $mailbox = $this->authService->getMailboxForUser($mailboxId, $userId);
553
554        $baseName = basename(str_replace('\\', '/', $folderPath));
555        $newPath = ($newParentFolder !== null && $newParentFolder !== '')
556            ? sprintf('%s/%s', rtrim($newParentFolder, '/'), $baseName)
557            : $baseName;
558
559        if ($newPath === $folderPath) {
560            return;
561        }
562
563        $driver = $this->connectDriver($mailbox);
564        $driver->renameFolder($folderPath, $newPath);
565        $driver->disconnect();
566
567        $order = $mailbox->folderOrder ?? [];
568        $updatedOrder = [];
569        foreach ($order as $p) {
570            if ($p === $folderPath) {
571                $updatedOrder[] = $newPath;
572            } elseif (str_starts_with($p, $folderPath . '/')) {
573                $updatedOrder[] = $newPath . substr($p, strlen($folderPath));
574            } else {
575                $updatedOrder[] = $p;
576            }
577        }
578        $this->cacheManager->updateFolderOrder($mailboxId, $updatedOrder);
579        try {
580            $rawFolders = $this->getMailboxFolders($mailboxId, $userId);
581            $this->cacheManager->saveCachedAccountFolders($mailboxId, $rawFolders);
582        } catch (Throwable) {
583            // Ignore cache refresh error
584        }
585    }
586
587    /**
588     * Renames an existing mailbox folder.
589     */
590    public function renameFolder(int $mailboxId, int $userId, string $oldPath, string $newName): void
591    {
592        $delim = '/';
593        $lastDelim = strrpos($oldPath, $delim);
594        $parent = $lastDelim !== false ? substr($oldPath, 0, $lastDelim) : null;
595        $newPath = $parent !== null ? sprintf('%s/%s', $parent, $newName) : $newName;
596
597        if ($newPath === $oldPath) {
598            return;
599        }
600
601        $mailbox = $this->authService->getMailboxForUser($mailboxId, $userId);
602        $driver = $this->connectDriver($mailbox);
603        $driver->renameFolder($oldPath, $newPath);
604        $driver->disconnect();
605
606        $order = $mailbox->folderOrder ?? [];
607        $updatedOrder = [];
608        foreach ($order as $p) {
609            if ($p === $oldPath) {
610                $updatedOrder[] = $newPath;
611            } elseif (str_starts_with($p, $oldPath . '/')) {
612                $updatedOrder[] = $newPath . substr($p, strlen($oldPath));
613            } else {
614                $updatedOrder[] = $p;
615            }
616        }
617        $this->cacheManager->updateFolderOrder($mailboxId, $updatedOrder);
618        try {
619            $rawFolders = $this->getMailboxFolders($mailboxId, $userId);
620            $this->cacheManager->saveCachedAccountFolders($mailboxId, $rawFolders);
621        } catch (Throwable) {
622            // Ignore cache refresh error
623        }
624    }
625
626    /**
627     * Deletes folder from mailbox and updates custom order.
628     */
629    public function deleteFolder(int $mailboxId, int $userId, string $folderPath): void
630    {
631        $mailbox = $this->authService->getMailboxForUser($mailboxId, $userId);
632        if ($this->organizer->isSystemFolder($mailbox, $folderPath)) {
633            throw new WebmailFolderException('Cannot delete system mailbox folder.');
634        }
635
636        $driver = $this->connectDriver($mailbox);
637        $driver->deleteFolder($folderPath);
638        $driver->disconnect();
639
640        $order = $mailbox->folderOrder ?? [];
641        $updatedOrder = array_values(array_filter($order, static fn(string $p): bool => $p !== $folderPath));
642        $this->cacheManager->updateFolderOrder($mailboxId, $updatedOrder);
643        try {
644            $rawFolders = $this->getMailboxFolders($mailboxId, $userId);
645            $this->cacheManager->saveCachedAccountFolders($mailboxId, $rawFolders);
646        } catch (Throwable) {
647            // Ignore cache refresh error
648        }
649    }
650
651    /**
652     * Deletes folder after migrating all existing messages to a target folder.
653     *
654     * @param int    $mailboxId    Mailbox ID.
655     * @param int    $userId       User ID.
656     * @param string $sourceFolder Folder to delete.
657     * @param string $targetFolder Destination folder for messages.
658     */
659    public function deleteFolderWithMigration(
660        int $mailboxId,
661        int $userId,
662        string $sourceFolder,
663        string $targetFolder
664    ): void {
665        $mailbox = $this->authService->getMailboxForUser($mailboxId, $userId);
666        if ($this->organizer->isSystemFolder($mailbox, $sourceFolder)) {
667            throw new WebmailFolderException('Cannot delete system mailbox folder.');
668        }
669
670        $driver = $this->connectDriver($mailbox);
671
672        // Fetch all message UIDs in the source folder
673        $criteria = new MailSearchCriteriaDto();
674        $fetched = $driver->fetchMessages($sourceFolder, $criteria, 1, 1000);
675        $uids = array_map(
676            static fn(MailMessageSummaryDto $m): string => $m->uid,
677            $fetched['messages'] ?? []
678        );
679
680        if ($uids !== []) {
681            $driver->moveMessages($sourceFolder, $targetFolder, $uids);
682        }
683
684        $driver->deleteFolder($sourceFolder);
685        $driver->disconnect();
686
687        $order = $mailbox->folderOrder ?? [];
688        $updatedOrder = array_values(array_filter($order, static fn(string $p): bool => $p !== $sourceFolder));
689        $this->cacheManager->updateFolderOrder($mailboxId, $updatedOrder);
690        try {
691            $rawFolders = $this->getMailboxFolders($mailboxId, $userId);
692            $this->cacheManager->saveCachedAccountFolders($mailboxId, $rawFolders);
693        } catch (Throwable) {
694            // Ignore cache refresh error
695        }
696    }
697
698    /**
699     * Retrieves storage quota statistics for the mailbox.
700     *
701     * @param int $mailboxId Mailbox ID.
702     * @param int $userId    User ID.
703     * @return array{
704     *     used_kb: int,
705     *     limit_kb: int,
706     *     percent: float,
707     *     used_human: string,
708     *     limit_human: string,
709     *     supported: bool
710     * }
711     */
712    public function getMailboxQuota(int $mailboxId, int $userId): array
713    {
714        $mailbox = $this->authService->getMailboxForUser($mailboxId, $userId);
715        $server = $this->mailRepo->findMailServerById($mailbox->mailServerId);
716        $driver = $this->driverRegistry->getDriver($mailbox->protocolType);
717        try {
718            $plainPass = $this->encryption->decrypt($mailbox->password);
719        } catch (EncryptionException) {
720            $plainPass = '';
721        }
722
723        $activeMailbox = new ClientMailbox(
724            id: $mailbox->id,
725            name: $mailbox->name,
726            email: $mailbox->email,
727            fromName: $mailbox->fromName,
728            mailServerId: $mailbox->mailServerId,
729            protocolType: $mailbox->protocolType,
730            username: $mailbox->username,
731            password: $plainPass,
732            folderInbox: $mailbox->folderInbox,
733            folderSent: $mailbox->folderSent,
734            folderDrafts: $mailbox->folderDrafts,
735            folderTrash: $mailbox->folderTrash,
736            folderSpam: $mailbox->folderSpam,
737            folderArchive: $mailbox->folderArchive,
738            signatureHtml: $mailbox->signatureHtml,
739            folderOrder: $mailbox->folderOrder
740        );
741
742        try {
743            $driver->connect($activeMailbox, $server);
744            $quota = $driver->getQuota();
745            $driver->disconnect();
746
747            if ($quota !== null) {
748                return [
749                    'used_kb'     => (int) ($quota['used_kb'] ?? 0),
750                    'limit_kb'    => (int) ($quota['limit_kb'] ?? $quota['total_kb'] ?? 0),
751                    'percent'     => (float) ($quota['percent'] ?? 0.0),
752                    'used_human'  => (string) ($quota['used_human'] ?? ''),
753                    'limit_human' => (string) ($quota['limit_human'] ?? ''),
754                    'supported'   => true,
755                ];
756            }
757        } catch (Throwable) {
758            // Graceful fallback if unsupported
759        }
760
761        return [
762            'used_kb'     => 0,
763            'limit_kb'    => 0,
764            'percent'     => 0.0,
765            'used_human'  => '0 KB',
766            'limit_human' => 'Unlimited',
767            'supported'   => false,
768        ];
769    }
770
771    /**
772     * Updates folder order array in database.
773     */
774    public function updateFolderOrder(int $mailboxId, array $folderOrder): void
775    {
776        $this->cacheManager->updateFolderOrder($mailboxId, $folderOrder);
777    }
778
779    /**
780     * Connects driver to active mailbox with decrypted password.
781     *
782     * @param ClientMailbox $mailbox Mailbox entity.
783     * @return MailProtocolDriverInterface Connected protocol driver.
784     */
785    private function connectDriver(ClientMailbox $mailbox): MailProtocolDriverInterface
786    {
787        return $this->connectionManager->connectDriver($mailbox);
788    }
789}