Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.08% |
306 / 312 |
|
88.89% |
32 / 36 |
CRAP | |
0.00% |
0 / 1 |
| WebmailHtmxController | |
98.07% |
305 / 311 |
|
88.89% |
32 / 36 |
89 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
1 | |||
| foldersTree | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| accountFolders | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| messagesList | |
100.00% |
62 / 62 |
|
100.00% |
1 / 1 |
10 | |||
| messagePreview | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| messageSecurityCheck | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| messageRelations | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| messageRelationsDetect | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| messageRelationsUnlink | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| toggleFlag | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
4.01 | |||
| composeModal | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| sendMessage | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| loginSubmit | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
2 | |||
| settingsModal | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
4 | |||
| saveSettings | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 | |||
| folderModal | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| createFolder | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| reorderMailboxes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| moveMailbox | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| reorderFolder | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| nestFolder | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| renameFolder | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| deleteFolder | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| deleteFolderModal | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| deleteFolderConfirm | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| mailboxInfoModal | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| unassignMailboxModal | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| unassignMailboxConfirm | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| messageAction | |
96.43% |
81 / 84 |
|
0.00% |
0 / 1 |
19 | |||
| messageSource | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| htmlResponse | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| resolveActionUids | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
7.07 | |||
| resolveBodyUids | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
5 | |||
| executeMoveAction | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
4.01 | |||
| buildRefreshToast | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
5 | |||
| unlockSession | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Mail\Presentation\Htmx; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Modules\Integrations\Application\Service\EmailSecurityScanner; |
| 12 | use App\Modules\Mail\Application\Contract\EmailContextAssociationServiceInterface; |
| 13 | use App\Modules\Mail\Application\Service\WebmailAuthService; |
| 14 | use App\Modules\Mail\Application\Service\WebmailFolderService; |
| 15 | use App\Modules\Mail\Application\Service\WebmailMessageService; |
| 16 | use App\Modules\Mail\Domain\Contract\WebmailSenderServiceInterface; |
| 17 | use App\Modules\Mail\Domain\Model\MailSearchCriteriaDto; |
| 18 | use App\Modules\Mail\Domain\Repository\MailRepositoryInterface; |
| 19 | use App\Modules\Pdf\Application\Service\PdfGeneratorServiceInterface; |
| 20 | use App\Modules\Pdf\Domain\Repository\PdfTemplateRepositoryInterface; |
| 21 | use PDO; |
| 22 | use Psr\Http\Message\ResponseFactoryInterface; |
| 23 | use Psr\Http\Message\ResponseInterface; |
| 24 | use Psr\Http\Message\ServerRequestInterface; |
| 25 | use Throwable; |
| 26 | use Twig\Environment as TwigEnvironment; |
| 27 | use 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 | */ |
| 37 | final 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 | } |