Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.85% covered (success)
97.85%
91 / 93
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailFolderTreeBuilder
97.83% covered (success)
97.83%
90 / 92
71.43% covered (warning)
71.43%
5 / 7
37
0.00% covered (danger)
0.00%
0 / 1
 applySpecialUseMappings
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
3
 resolveSpecialUseRole
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
9.06
 buildFolderHierarchy
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 assembleHierarchyNode
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
3
 compareFolderOrder
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 compareConfigOrder
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 flattenFolderPaths
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
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\Folder;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Mail\Domain\Model\ClientMailbox;
12use App\Modules\Mail\Domain\Model\MailFolderDto;
13
14/**
15 * Webmail folder hierarchy tree builder and ordering service.
16 *
17 * Constructs nested parent-child folder structures, applies special-use roles
18 * (inbox, sent, drafts, archive, spam, trash), and orders siblings according
19 * to system priorities and custom user arrangements.
20 *
21 * @package App\Modules\Mail\Application\Service\Folder
22 */
23final readonly class WebmailFolderTreeBuilder
24{
25    /**
26     * Applies mailbox-specific configured special use roles to folder list.
27     *
28     * @param array<MailFolderDto> $folders Raw folder tree.
29     * @param ClientMailbox        $mailbox Mailbox definition with mappings.
30     * @param bool                 $isChild Whether current recursion level is inside a parent folder.
31     * @return array<MailFolderDto> Folders with updated special-use roles.
32     */
33    public function applySpecialUseMappings(
34        array $folders,
35        ClientMailbox $mailbox,
36        bool $isChild = false
37    ): array {
38        $result = [];
39
40        foreach ($folders as $folder) {
41            $role = $this->resolveSpecialUseRole($folder, $mailbox, $isChild);
42            $children = $folder->children !== []
43                ? $this->applySpecialUseMappings($folder->children, $mailbox, true)
44                : [];
45
46            $result[] = new MailFolderDto(
47                id: $folder->id,
48                name: $folder->name,
49                path: $folder->path,
50                delimiter: $folder->delimiter,
51                attributes: $folder->attributes,
52                specialUse: $role,
53                totalMessages: $folder->totalMessages,
54                unseenMessages: $folder->unseenMessages,
55                uidNext: $folder->uidNext,
56                uidValidity: $folder->uidValidity,
57                highestModseq: $folder->highestModseq,
58                children: $children
59            );
60        }
61
62        return $result;
63    }
64
65    /**
66     * Resolves special-use role for a folder based on mailbox configured mappings.
67     *
68     * @param MailFolderDto $folder Folder instance.
69     * @param ClientMailbox $mailbox Mailbox definition.
70     * @param bool $isChild Whether folder is a child folder.
71     * @return string|null Resolved role name.
72     */
73    public function resolveSpecialUseRole(
74        MailFolderDto $folder,
75        ClientMailbox $mailbox,
76        bool $isChild
77    ): ?string {
78        if ($isChild) {
79            return null;
80        }
81
82        return match ($folder->path) {
83            $mailbox->folderSent    => 'sent',
84            $mailbox->folderDrafts  => 'drafts',
85            $mailbox->folderTrash   => 'trash',
86            $mailbox->folderSpam    => 'spam',
87            $mailbox->folderArchive => 'archive',
88            $mailbox->folderInbox   => 'inbox',
89            default                 => $folder->specialUse,
90        };
91    }
92
93    /**
94     * Constructs recursive folder hierarchy and sorts sibling nodes by custom order.
95     *
96     * @param array<MailFolderDto> $flatFolders Flat list of folders.
97     * @param array<string>|null   $orderConfig Saved folder order array.
98     * @return array<MailFolderDto> Nested and sorted tree nodes.
99     */
100    public function buildFolderHierarchy(array $flatFolders, ?array $orderConfig = null): array
101    {
102        $byPath = [];
103        foreach ($flatFolders as $f) {
104            $byPath[$f->path] = $f;
105        }
106
107        $childrenMap = [];
108        foreach ($flatFolders as $f) {
109            $delim = $f->delimiter !== '' ? $f->delimiter : '/';
110            $lastDelim = strrpos($f->path, $delim);
111            $parentPath = $lastDelim !== false ? substr($f->path, 0, $lastDelim) : null;
112            if ($parentPath !== null && !isset($byPath[$parentPath])) {
113                $parentPath = null;
114            }
115            $childrenMap[$parentPath][] = $f;
116        }
117
118        return $this->assembleHierarchyNode(null, $childrenMap, $orderConfig);
119    }
120
121    /**
122     * Recursively builds tree nodes and applies sibling ordering.
123     *
124     * @param string|null                              $parentPath  Current parent path.
125     * @param array<string|null, array<MailFolderDto>> $childrenMap Grouped children map.
126     * @param array<string>|null                       $orderConfig Custom order list.
127     * @return array<MailFolderDto> Sorted children DTOs with their nested subfolders.
128     */
129    private function assembleHierarchyNode(?string $parentPath, array $childrenMap, ?array $orderConfig): array
130    {
131        $items = $childrenMap[$parentPath] ?? [];
132        if ($items === []) {
133            return [];
134        }
135
136        $result = [];
137        foreach ($items as $item) {
138            $children = $this->assembleHierarchyNode($item->path, $childrenMap, $orderConfig);
139            $result[] = new MailFolderDto(
140                id: $item->id,
141                name: $item->name,
142                path: $item->path,
143                delimiter: $item->delimiter,
144                attributes: $item->attributes,
145                specialUse: $item->specialUse,
146                totalMessages: $item->totalMessages,
147                unseenMessages: $item->unseenMessages,
148                uidNext: $item->uidNext,
149                uidValidity: $item->uidValidity,
150                highestModseq: $item->highestModseq,
151                children: $children
152            );
153        }
154
155        usort($result, fn (MailFolderDto $a, MailFolderDto $b): int => $this->compareFolderOrder(
156            $a,
157            $b,
158            $orderConfig
159        ));
160
161        return $result;
162    }
163
164    /**
165     * Compares two folder DTOs for sorting.
166     *
167     * @param MailFolderDto $a First folder.
168     * @param MailFolderDto $b Second folder.
169     * @param array<string>|null $orderConfig Custom order list.
170     * @return int Comparison result.
171     */
172    public function compareFolderOrder(MailFolderDto $a, MailFolderDto $b, ?array $orderConfig): int
173    {
174        if ($orderConfig !== null && $orderConfig !== []) {
175            $configDiff = $this->compareConfigOrder($a, $b, $orderConfig);
176            if ($configDiff !== null) {
177                return $configDiff;
178            }
179        }
180
181        $priorityMap = [
182            'inbox'   => 1,
183            'sent'    => 2,
184            'drafts'  => 3,
185            'archive' => 4,
186            'spam'    => 5,
187            'trash'   => 6,
188        ];
189
190        $prioA = $a->isInbox() ? 1 : ($priorityMap[$a->specialUse ?? ''] ?? 100);
191        $prioB = $b->isInbox() ? 1 : ($priorityMap[$b->specialUse ?? ''] ?? 100);
192
193        if ($prioA !== $prioB) {
194            return $prioA <=> $prioB;
195        }
196
197        return strcasecmp($a->name, $b->name);
198    }
199
200    /**
201     * Compares folder positions within explicit configuration array.
202     *
203     * @param MailFolderDto $a First folder.
204     * @param MailFolderDto $b Second folder.
205     * @param array<string> $orderConfig Configured path order.
206     * @return int|null Comparison result or null if neither configured.
207     */
208    private function compareConfigOrder(MailFolderDto $a, MailFolderDto $b, array $orderConfig): ?int
209    {
210        $posA = array_search($a->path, $orderConfig, true);
211        $posB = array_search($b->path, $orderConfig, true);
212        if ($posA !== false && $posB !== false) {
213            return $posA <=> $posB;
214        }
215        if ($posA !== false) {
216            return -1;
217        }
218
219        return $posB !== false ? 1 : null;
220    }
221
222    /**
223     * Recursively extracts flat list of folder DTOs.
224     *
225     * @param array<MailFolderDto> $folders Hierarchy.
226     * @return array<MailFolderDto> Flattened folders.
227     */
228    public function flattenFolderPaths(array $folders): array
229    {
230        $result = [];
231        foreach ($folders as $f) {
232            $result[] = $f;
233            if ($f->hasChildren()) {
234                $result = array_merge($result, $this->flattenFolderPaths($f->children));
235            }
236        }
237
238        return $result;
239    }
240}