Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.14% covered (success)
90.14%
64 / 71
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailFolderCacheManager
90.00% covered (success)
90.00%
63 / 70
44.44% covered (danger)
44.44%
4 / 9
33.02
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
 saveCachedAccountFolders
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 getCachedAccountFolders
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getFoldersFromMemoryCache
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
5.93
 getFoldersFromDatabaseCache
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 parseFoldersJson
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 storeFoldersInMemoryCache
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
8.74
 updateSpecialFoldersMapping
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 updateFolderOrder
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
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\MailFolderDto;
12use PDO;
13use Throwable;
14use Yiisoft\Cache\CacheInterface;
15
16/**
17 * Webmail folder persistence and caching manager.
18 *
19 * Handles database record synchronization, JSON serialization/deserialization,
20 * and memory cache lifecycle for client mailbox folder trees.
21 *
22 * @package App\Modules\Mail\Application\Service\Folder
23 */
24final readonly class WebmailFolderCacheManager
25{
26    /**
27     * WebmailFolderCacheManager constructor.
28     *
29     * @param PDO $pdo Database connection.
30     * @param CacheInterface|null $cache Optional cache implementation.
31     */
32    public function __construct(
33        private PDO $pdo,
34        private ?CacheInterface $cache = null
35    ) {
36    }
37
38    /**
39     * Saves folder tree hierarchy into database row and cache backend.
40     *
41     * @param int                  $mailboxId Mailbox primary key.
42     * @param array<MailFolderDto> $folders   Folder hierarchy.
43     */
44    public function saveCachedAccountFolders(int $mailboxId, array $folders): void
45    {
46        $serialized = [];
47        foreach ($folders as $folder) {
48            $serialized[] = $folder->toArray();
49        }
50
51        try {
52            $json = json_encode($serialized, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
53
54            $sql = 'UPDATE `a_mod_client_mailboxes_records` SET `cached_folders` = :folders WHERE `id` = :id';
55            $stmt = $this->pdo->prepare($sql);
56            $stmt->execute([
57                ':folders' => $json,
58                ':id'      => $mailboxId,
59            ]);
60        } catch (Throwable) {
61            // Ignore persistence errors if column not ready
62        }
63
64        if ($this->cache !== null) {
65            try {
66                $this->cache->psr()->set('webmail_folders_' . $mailboxId, $folders, 86400);
67            } catch (Throwable) {
68                // Ignore cache error
69            }
70        }
71    }
72
73    /**
74     * Retrieves cached folder hierarchy from memory cache or database.
75     *
76     * @param int $mailboxId Mailbox primary key.
77     * @return array<MailFolderDto>|null Cached folder tree or null if none.
78     */
79    public function getCachedAccountFolders(int $mailboxId): ?array
80    {
81        $cached = $this->getFoldersFromMemoryCache($mailboxId);
82        if ($cached !== null) {
83            return $cached;
84        }
85
86        return $this->getFoldersFromDatabaseCache($mailboxId);
87    }
88
89    /**
90     * Retrieves cached folders from PSR memory cache.
91     *
92     * @param int $mailboxId Mailbox ID.
93     * @return array<MailFolderDto>|null Cached folders or null.
94     */
95    private function getFoldersFromMemoryCache(int $mailboxId): ?array
96    {
97        if ($this->cache === null) {
98            return null;
99        }
100
101        try {
102            /** @var array<MailFolderDto>|null $cached */
103            $cached = $this->cache->psr()->get('webmail_folders_' . $mailboxId);
104            return (is_array($cached) && $cached !== []) ? $cached : null;
105        } catch (Throwable) {
106            return null;
107        }
108    }
109
110    /**
111     * Retrieves cached folders from database and syncs to memory cache.
112     *
113     * @param int $mailboxId Mailbox ID.
114     * @return array<MailFolderDto>|null Cached folders or null.
115     */
116    private function getFoldersFromDatabaseCache(int $mailboxId): ?array
117    {
118        try {
119            $sql = 'SELECT `cached_folders` FROM `a_mod_client_mailboxes_records` WHERE `id` = :id';
120            $stmt = $this->pdo->prepare($sql);
121            $stmt->execute([':id' => $mailboxId]);
122            $val = $stmt->fetchColumn();
123
124            $folders = is_string($val) && $val !== '' ? $this->parseFoldersJson($val) : null;
125            if ($folders !== null) {
126                $this->storeFoldersInMemoryCache($mailboxId, $folders);
127            }
128
129            return $folders;
130        } catch (Throwable) {
131            return null;
132        }
133    }
134
135    /**
136     * Decodes and maps JSON string to MailFolderDto list.
137     *
138     * @param string $json JSON encoded folder records.
139     * @return array<MailFolderDto>|null List of folder DTOs or null on invalid format.
140     */
141    private function parseFoldersJson(string $json): ?array
142    {
143        $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
144        if (!is_array($data)) {
145            return null;
146        }
147
148        $folders = [];
149        foreach ($data as $folderData) {
150            if (is_array($folderData)) {
151                $folders[] = MailFolderDto::fromArray($folderData);
152            }
153        }
154
155        return $folders;
156    }
157
158    /**
159     * Stores folder array in memory cache.
160     *
161     * @param int $mailboxId Mailbox ID.
162     * @param array<MailFolderDto> $folders Folder DTOs.
163     */
164    private function storeFoldersInMemoryCache(int $mailboxId, array $folders): void
165    {
166        if ($this->cache !== null && $folders !== []) {
167            try {
168                $this->cache->psr()->set('webmail_folders_' . $mailboxId, $folders, 86400);
169            } catch (Throwable) {
170                // Ignore cache write errors
171            }
172        }
173    }
174
175    /**
176     * Updates configurable special folders mapping for a specific mailbox.
177     *
178     * @param int                    $mailboxId Mailbox primary key.
179     * @param array<string, ?string> $mapping   Folder mappings ['sent' => '...', 'trash' => '...'].
180     */
181    public function updateSpecialFoldersMapping(int $mailboxId, array $mapping): void
182    {
183        $sql = 'UPDATE `a_mod_client_mailboxes_records` SET '
184            . '`folder_inbox` = :inbox, `folder_sent` = :sent, `folder_drafts` = :drafts, '
185            . '`folder_trash` = :trash, `folder_spam` = :spam, `folder_archive` = :archive '
186            . 'WHERE `id` = :id';
187
188        $resolveMapping = static fn(?string $val): ?string => isset($val) && $val !== '' ? (string) $val : null;
189
190        $stmt = $this->pdo->prepare($sql);
191        $stmt->execute([
192            ':inbox'   => (string) ($mapping['inbox'] ?? 'INBOX'),
193            ':sent'    => $resolveMapping($mapping['sent'] ?? null),
194            ':drafts'  => $resolveMapping($mapping['drafts'] ?? null),
195            ':trash'   => $resolveMapping($mapping['trash'] ?? null),
196            ':spam'    => $resolveMapping($mapping['spam'] ?? null),
197            ':archive' => $resolveMapping($mapping['archive'] ?? null),
198            ':id'      => $mailboxId,
199        ]);
200    }
201
202    /**
203     * Updates folder order array in database.
204     *
205     * @param int $mailboxId Mailbox primary key.
206     * @param array<string> $folderOrder Ordered folder paths.
207     */
208    public function updateFolderOrder(int $mailboxId, array $folderOrder): void
209    {
210        $sql = 'UPDATE `a_mod_client_mailboxes_records` SET `folder_order` = :order WHERE `id` = :id';
211        $stmt = $this->pdo->prepare($sql);
212        $stmt->execute([
213            ':order' => json_encode(array_values(array_unique($folderOrder))),
214            ':id'    => $mailboxId,
215        ]);
216
217        if ($this->cache !== null) {
218            try {
219                $this->cache->psr()->delete('webmail_folders_' . $mailboxId);
220            } catch (Throwable) {
221                // Ignore cache invalidation failure
222            }
223        }
224    }
225}