Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.05% covered (success)
95.05%
173 / 182
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailApiController
95.03% covered (success)
95.03%
172 / 181
54.55% covered (warning)
54.55%
6 / 11
37
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
 accounts
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 connect
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 folders
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 messages
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 messageDetail
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
2
 downloadAttachment
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 send
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
7
 reauth
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
3.05
 checkNew
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
5
 resolveUserId
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
6.99
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\Api;
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\Application\Service\WebmailMessageService;
14use App\Modules\Mail\Application\Service\WebmailSyncService;
15use App\Modules\Mail\Domain\Contract\WebmailSenderServiceInterface;
16use App\Modules\Mail\Domain\Model\MailOutgoingMessageDto;
17use App\Modules\Mail\Domain\Model\MailSearchCriteriaDto;
18use App\Shared\Infrastructure\Http\ApiResponseTrait;
19use Psr\Http\Message\ResponseFactoryInterface;
20use Psr\Http\Message\ResponseInterface;
21use Psr\Http\Message\ServerRequestInterface;
22use Throwable;
23use Yiisoft\User\CurrentUser;
24
25/**
26 * REST API Controller for Webmail Headless Operations.
27 *
28 * Exposes versioned endpoints (/api/v1/mail/webmail/*) for folder listings,
29 * message streaming, isolated body retrieval, and attachment downloads.
30 *
31 * @package App\Modules\Mail\Presentation\Api
32 */
33final readonly class WebmailApiController
34{
35    use ApiResponseTrait;
36
37    /**
38     * WebmailApiController constructor.
39     *
40     * @param ResponseFactoryInterface           $responseFactory PSR-17 response factory.
41     * @param CurrentUser                        $currentUser     Authenticated identity.
42     * @param WebmailAuthService                 $authService     Webmail authentication service.
43     * @param WebmailFolderService               $folderService   Folder hierarchy service.
44     * @param WebmailMessageService              $messageService  Message service.
45     * @param WebmailSenderServiceInterface       $senderService   Sender service.
46     */
47    public function __construct(
48        private ResponseFactoryInterface $responseFactory,
49        private CurrentUser $currentUser,
50        private WebmailAuthService $authService,
51        private WebmailFolderService $folderService,
52        private WebmailMessageService $messageService,
53        private WebmailSenderServiceInterface $senderService,
54        private ?WebmailSyncService $syncService = null
55    ) {
56    }
57
58    /**
59     * GET /api/v1/mail/webmail/accounts
60     */
61    public function accounts(): ResponseInterface
62    {
63        $userId = (int) $this->currentUser->getId();
64        $mailboxes = $this->authService->getUserMailboxes($userId);
65
66        $data = array_map(static fn($m): array => [
67            'id'             => $m->id,
68            'name'           => $m->name,
69            'email'          => $m->email,
70            'from_name'      => $m->fromName,
71            'protocol_type'  => $m->protocolType,
72            'sync_status'    => $m->syncStatus,
73            'last_sync_at'   => $m->lastSyncAt,
74            'folder_inbox'   => $m->folderInbox,
75            'folder_sent'    => $m->folderSent,
76            'folder_drafts'  => $m->folderDrafts,
77            'folder_trash'   => $m->folderTrash,
78            'folder_spam'    => $m->folderSpam,
79            'folder_archive' => $m->folderArchive,
80        ], $mailboxes);
81
82        return $this->jsonSuccess($this->responseFactory, ['accounts' => $data]);
83    }
84
85    /**
86     * POST /api/v1/mail/webmail/connect
87     */
88    public function connect(ServerRequestInterface $request): ResponseInterface
89    {
90        $userId = (int) $this->currentUser->getId();
91        $body   = $this->parseJsonBody($request);
92
93        $email    = (string) ($body['email'] ?? '');
94        $password = (string) ($body['password'] ?? '');
95        $serverId = (int) ($body['mail_server_id'] ?? 0);
96        $remember = (bool) ($body['remember_credentials'] ?? true);
97
98        if ($email === '' || $password === '' || $serverId <= 0) {
99            return $this->jsonValidation($this->responseFactory, [
100                'email'          => ['Email is required.'],
101                'password'       => ['Password is required.'],
102                'mail_server_id' => ['Valid mail server ID is required.'],
103            ]);
104        }
105
106        try {
107            $mailbox = $this->authService->connectAndLinkMailbox($userId, $email, $password, $serverId, $remember);
108            return $this->jsonSuccess($this->responseFactory, [
109                'mailbox_id' => $mailbox->id,
110                'email'      => $mailbox->email,
111                'message'    => 'Mailbox connected successfully.',
112            ]);
113        } catch (Throwable $e) {
114            return $this->jsonError($this->responseFactory, $e->getMessage(), 400);
115        }
116    }
117
118    /**
119     * GET /api/v1/mail/webmail/mailboxes/{id}/folders
120     */
121    public function folders(int $id): ResponseInterface
122    {
123        $userId = (int) $this->currentUser->getId();
124        $tree   = $this->folderService->buildMultiAccountFolderTree($userId);
125
126        $targetFolders = [];
127        foreach ($tree as $node) {
128            if ($node['mailbox']->id === $id) {
129                $targetFolders = $node['folders'];
130                break;
131            }
132        }
133
134        return $this->jsonSuccess($this->responseFactory, ['folders' => $targetFolders]);
135    }
136
137    /**
138     * GET /api/v1/mail/webmail/mailboxes/{id}/messages
139     */
140    public function messages(ServerRequestInterface $request, int $id): ResponseInterface
141    {
142        $userId = (int) $this->currentUser->getId();
143        $params = $request->getQueryParams();
144
145        $folder   = (string) ($params['folder'] ?? 'INBOX');
146        $page     = max(1, (int) ($params['page'] ?? 1));
147        $perPage  = min(100, max(1, (int) ($params['per_page'] ?? 25)));
148        $query    = isset($params['q']) && $params['q'] !== '' ? (string) $params['q'] : null;
149
150        $criteria = new MailSearchCriteriaDto(
151            folder: $folder,
152            query: $query,
153            isUnreadOnly: ($params['unread_only'] ?? '0') === '1',
154            isFlaggedOnly: ($params['flagged_only'] ?? '0') === '1',
155            hasAttachmentsOnly: ($params['has_attachments'] ?? '0') === '1',
156            sortDirection: (string) ($params['sort_dir'] ?? 'desc')
157        );
158
159        try {
160            $result = $this->messageService->fetchMessages($id, $userId, $criteria, $page, $perPage);
161            return $this->jsonSuccess($this->responseFactory, $result);
162        } catch (Throwable $e) {
163            return $this->jsonError($this->responseFactory, $e->getMessage(), 500);
164        }
165    }
166
167    /**
168     * GET /api/v1/mail/webmail/mailboxes/{id}/messages/{uid}
169     */
170    public function messageDetail(ServerRequestInterface $request, int $id, string $uid): ResponseInterface
171    {
172        $userId = (int) $this->currentUser->getId();
173        $params = $request->getQueryParams();
174        $folder = (string) ($params['folder'] ?? 'INBOX');
175        $allowImages = ($params['allow_images'] ?? '0') === '1';
176
177        try {
178            $detail = $this->messageService->getMessageDetail($id, $userId, $folder, $uid, true, $allowImages);
179            return $this->jsonSuccess($this->responseFactory, [
180                'uid'         => $detail->summary->uid,
181                'subject'     => $detail->summary->subject,
182                'from_name'   => $detail->summary->fromName,
183                'from_email'  => $detail->summary->fromEmail,
184                'to'          => $detail->summary->to,
185                'date'        => $detail->summary->date,
186                'html_body'   => $detail->htmlBody,
187                'text_body'   => $detail->textBody,
188                'attachments' => array_map(static fn($a): array => [
189                    'part_id'   => $a->partId,
190                    'filename'  => $a->filename,
191                    'mime_type' => $a->mimeType,
192                    'size'      => $a->size,
193                    'is_inline' => $a->isInline,
194                ], $detail->attachments),
195                'spf_status'  => $detail->spfStatus,
196                'dkim_status' => $detail->dkimStatus,
197                'dmarc_status'=> $detail->dmarcStatus,
198                'has_blocked_images' => $detail->hasBlockedImages,
199            ]);
200        } catch (Throwable $e) {
201            return $this->jsonError($this->responseFactory, $e->getMessage(), 500);
202        }
203    }
204
205    /**
206     * GET /api/v1/mail/webmail/mailboxes/{id}/messages/{uid}/parts/{partId}
207     */
208    public function downloadAttachment(
209        ServerRequestInterface $request,
210        int $id,
211        string $uid,
212        string $partId
213    ): ResponseInterface {
214        $userId = (int) $this->currentUser->getId();
215        $params = $request->getQueryParams();
216        $folder = (string) ($params['folder'] ?? 'INBOX');
217
218        try {
219            $part = $this->messageService->getMimePart($id, $userId, $folder, $uid, $partId);
220            $filename = $part->filename ?? sprintf('attachment_%s.bin', $partId);
221
222            $response = $this->responseFactory->createResponse(200)
223                ->withHeader('Content-Type', $part->mimeType)
224                ->withHeader('Content-Disposition', sprintf('attachment; filename="%s"', addcslashes($filename, '"')))
225                ->withHeader('X-Content-Type-Options', 'nosniff');
226            $response->getBody()->write($part->content);
227
228            return $response;
229        } catch (Throwable $e) {
230            return $this->jsonError($this->responseFactory, $e->getMessage(), 500);
231        }
232    }
233
234    /**
235     * POST /api/v1/mail/webmail/mailboxes/{id}/send
236     */
237    public function send(ServerRequestInterface $request, int $id): ResponseInterface
238    {
239        $userId = (int) $this->currentUser->getId();
240        $body   = $this->parseJsonBody($request);
241
242        $to       = (array) ($body['to'] ?? []);
243        $subject  = (string) ($body['subject'] ?? '');
244        $htmlBody = (string) ($body['html_body'] ?? '');
245        $textBody = (string) ($body['text_body'] ?? '');
246
247        if ($to === []) {
248            return $this->jsonValidation($this->responseFactory, [
249                'to' => ['At least one recipient address is required.'],
250            ]);
251        }
252
253        $mailboxes = $this->authService->getUserMailboxes($userId);
254        $mbox = null;
255        foreach ($mailboxes as $m) {
256            if ($m->id === $id) {
257                $mbox = $m;
258                break;
259            }
260        }
261
262        if ($mbox === null) {
263            return $this->jsonError($this->responseFactory, 'Mailbox not found.', 404);
264        }
265
266        $msg = new MailOutgoingMessageDto(
267            mailboxId: $id,
268            fromEmail: $mbox->email,
269            fromName: $mbox->fromName,
270            to: $to,
271            subject: $subject,
272            htmlBody: $htmlBody,
273            textBody: $textBody !== '' ? $textBody : strip_tags($htmlBody)
274        );
275
276        $res = $this->senderService->sendMessage($userId, $msg);
277        return $res->isSuccess
278            ? $this->jsonSuccess($this->responseFactory, ['message_id' => $res->messageId])
279            : $this->jsonError($this->responseFactory, $res->message, 502);
280    }
281
282    /**
283     * POST /api/v1/mail/webmail/mailboxes/{id}/reauth
284     */
285    public function reauth(ServerRequestInterface $request, int $id): ResponseInterface
286    {
287        $userId = (int) $this->currentUser->getId();
288        $body   = $this->parseJsonBody($request);
289        $newPassword = (string) ($body['password'] ?? '');
290
291        if ($newPassword === '') {
292            return $this->jsonValidation($this->responseFactory, [
293                'password' => ['New password is required.'],
294            ]);
295        }
296
297        try {
298            $this->authService->reauthenticateMailbox($id, $userId, $newPassword);
299            return $this->jsonSuccess($this->responseFactory, ['message' => 'Credentials updated successfully.']);
300        } catch (Throwable $e) {
301            return $this->jsonError($this->responseFactory, $e->getMessage(), 400);
302        }
303    }
304
305    /**
306     * GET or POST /api/v1/mail/webmail/check-new
307     *
308     * Micro-sync polling endpoint for background mail checks and desktop notification digests.
309     *
310     * @param ServerRequestInterface $request Server request.
311     * @return ResponseInterface JSON API response.
312     */
313    public function checkNew(ServerRequestInterface $request): ResponseInterface
314    {
315        if ($this->syncService === null) {
316            return $this->jsonError($this->responseFactory, 'WebmailSyncService is not configured.', 500);
317        }
318
319        $userId = $this->resolveUserId($request);
320        $force = ($request->getQueryParams()['force'] ?? '0') === '1';
321
322        if (session_status() === PHP_SESSION_ACTIVE) {
323            session_write_close();
324        }
325
326        try {
327            $mailboxes = $this->authService->getUserMailboxes($userId);
328            $result = $mailboxes !== []
329                ? $this->syncService->syncMailboxes($mailboxes, $force)
330                : [
331                    'has_new'        => false,
332                    'new_count'      => 0,
333                    'total_unseen'   => 0,
334                    'badge_text'     => '0',
335                    'mailbox_counts' => [],
336                    'messages'       => [],
337                ];
338
339            return $this->jsonSuccess($this->responseFactory, [
340                'has_mailboxes'  => $mailboxes !== [],
341                'has_new'        => $result['has_new'],
342                'new_count'      => $result['new_count'],
343                'total_unseen'   => $result['total_unseen'],
344                'badge_text'     => $result['badge_text'] ?? (string) $result['total_unseen'],
345                'mailbox_counts' => $result['mailbox_counts'] ?? [],
346                'messages'       => $result['messages'],
347            ]);
348        } catch (Throwable $e) {
349            return $this->jsonError($this->responseFactory, $e->getMessage(), 500);
350        }
351    }
352
353    /**
354     * Resolves authenticated user ID from CurrentUser service or request attribute.
355     *
356     * @param ServerRequestInterface $request HTTP Server request.
357     * @return int Resolved user ID or 0 if unauthenticated.
358     */
359    private function resolveUserId(ServerRequestInterface $request): int
360    {
361        $sessionUserId = (int) $this->currentUser->getId();
362        if ($sessionUserId > 0) {
363            return $sessionUserId;
364        }
365
366        $userAttr = $request->getAttribute('user');
367        if (is_array($userAttr) && !empty($userAttr['id'])) {
368            return (int) $userAttr['id'];
369        }
370
371        return 0;
372    }
373}