Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.65% covered (warning)
84.65%
419 / 495
51.61% covered (warning)
51.61%
16 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailComposeHtmxController
84.62% covered (warning)
84.62%
418 / 494
51.61% covered (warning)
51.61%
16 / 31
229.84
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
 composeModal
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
4
 enrichWithRecordAndPdf
85.19% covered (warning)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
14.64
 enrichWithCalendarInvite
97.06% covered (success)
97.06%
33 / 34
0.00% covered (danger)
0.00%
0 / 1
8
 collectAttendeeEmails
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 renderCalendarInvitationBody
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
3
 generatePreloadedIcsAttachment
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
2.02
 lookupCalendarEvent
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
5.07
 extractEventAttendees
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
11
 resolveEventOrganizer
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
7.01
 formatCalendarDate
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
7.13
 sendMessage
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 resolveDefaultMailboxId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 resolveComposePrefillData
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 resolveExistingMessagePrefill
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 resolveReplyOrForwardPrefill
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 resolveCustomComposePrefill
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
4.25
 resolveReplyPrefill
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
4.03
 resolveReplyAllPrefill
33.33% covered (danger)
33.33%
6 / 18
0.00% covered (danger)
0.00%
0 / 1
8.74
 resolveForwardPrefill
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 resolveSignaturePrefill
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 findActiveMailbox
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 buildOutgoingMessage
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
5
 extractPreloadedAttachments
26.32% covered (danger)
26.32%
5 / 19
0.00% covered (danger)
0.00%
0 / 1
26.60
 resolveModuleRecordPrefill
64.52% covered (warning)
64.52%
20 / 31
0.00% covered (danger)
0.00%
0 / 1
20.55
 generatePreloadedPdfAttachment
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 findFirstActiveTemplate
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 stageFile
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
3.01
 extractUploadedAttachments
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 saveOutboundEmailRecord
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 htmlResponse
100.00% covered (success)
100.00%
4 / 4
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\Presentation\Htmx;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Template\Application\Service\TemplateVariableResolver;
12use App\Modules\Calendar\Application\Service\CalendarIcsGenerator;
13use App\Modules\Mail\Application\Service\WebmailAuthService;
14use App\Modules\Mail\Application\Service\WebmailMessageService;
15use App\Modules\Mail\Domain\Contract\WebmailSenderServiceInterface;
16use App\Modules\Mail\Domain\Model\ClientMailbox;
17use App\Modules\Mail\Domain\Model\MailOutgoingMessageDto;
18use App\Modules\Mail\Domain\Repository\MailRepositoryInterface;
19use App\Modules\Pdf\Application\Service\PdfGeneratorServiceInterface;
20use App\Modules\Pdf\Domain\Repository\PdfTemplateRepositoryInterface;
21use DateTimeImmutable;
22use DateTimeZone;
23use PDO;
24use Psr\Http\Message\ResponseFactoryInterface;
25use Psr\Http\Message\ResponseInterface;
26use Psr\Http\Message\ServerRequestInterface;
27use Psr\Http\Message\UploadedFileInterface;
28use Throwable;
29use Twig\Environment as TwigEnvironment;
30use Yiisoft\User\CurrentUser;
31
32/**
33 * Handles Webmail HTMX interactions for email drafting and composing.
34 *
35 * @package App\Modules\Mail\Presentation\Htmx
36 */
37final readonly class WebmailComposeHtmxController
38{
39    private const string TEMPLATE_ALERT = 'mail/webmail/partials/alert.twig';
40
41    public function __construct(
42        private TwigEnvironment $twig,
43        private CurrentUser $currentUser,
44        private ResponseFactoryInterface $responseFactory,
45        private WebmailAuthService $authService,
46        private WebmailMessageService $messageService,
47        private WebmailSenderServiceInterface $senderService,
48        private MailRepositoryInterface $mailRepo,
49        private ?PdfGeneratorServiceInterface $pdfGenerator = null,
50        private ?PdfTemplateRepositoryInterface $pdfTemplateRepo = null,
51        private ?PDO $pdo = null,
52    ) {
53    }
54
55    /**
56     * Renders compose drawer partial.
57     */
58    public function composeModal(ServerRequestInterface $request): ResponseInterface
59    {
60        $userId = (int) $this->currentUser->getId();
61        $mailboxes = $this->authService->getUserMailboxes($userId);
62
63        $params = $request->getQueryParams();
64        $ticketId = isset($params['ticket_id']) ? (int) $params['ticket_id'] : 0;
65        $defaultMboxId = $this->resolveDefaultMailboxId($mailboxes);
66        $mailboxId = isset($params['mailbox_id']) ? (int) $params['mailbox_id'] : $defaultMboxId;
67
68        [$subject, $to, $cc, $bcc, $replyBody, $inReplyTo] = $this->resolveComposePrefillData(
69            $mailboxes,
70            $mailboxId,
71            $userId,
72            $params
73        );
74
75        [$to, $subject, $replyBody, $preloadedAttachments, $bcc] = $this->enrichWithRecordAndPdf(
76            $params,
77            $to,
78            $subject,
79            $replyBody,
80            $bcc
81        );
82
83        $html = $this->twig->render('mail/webmail/partials/compose_drawer.twig', [
84            'mailboxes'             => $mailboxes,
85            'mailbox_id'            => $mailboxId,
86            'subject'               => $subject,
87            'to'                    => $to,
88            'cc'                    => $cc,
89            'bcc'                   => $bcc,
90            'body'                  => $replyBody,
91            'ticket_id'             => $ticketId > 0 ? $ticketId : null,
92            'in_reply_to'           => $inReplyTo,
93            'preloaded_attachments' => $preloadedAttachments,
94        ]);
95
96        return $this->htmlResponse($html);
97    }
98
99    /**
100     * @param array<string, mixed> $params
101     * @return array{0: string, 1: string, 2: string, 3: array<int, array<string, string>>, 4: string}
102     */
103    private function enrichWithRecordAndPdf(
104        array $params,
105        string $to,
106        string $subject,
107        string $replyBody,
108        string $bcc = ''
109    ): array {
110        $module = (string) ($params['module'] ?? '');
111        $recordId = (int) ($params['record_id'] ?? 0);
112        $attachPdf = (string) ($params['attach_pdf'] ?? '') === '1';
113        $preloadedAttachments = [];
114
115        if ($module === '' || $recordId <= 0 || $this->pdo === null) {
116            return [$to, $subject, $replyBody, $preloadedAttachments, $bcc];
117        }
118
119        if (in_array(strtolower($module), ['calendar', 'calendar_records'], true)) {
120            return $this->enrichWithCalendarInvite(
121                $recordId,
122                $params,
123                $to,
124                $subject,
125                $replyBody,
126                $bcc
127            );
128        }
129
130        $recordPrefill = $this->resolveModuleRecordPrefill($module, $recordId);
131        if ($to === '' && $recordPrefill['to'] !== '') {
132            $to = $recordPrefill['to'];
133        }
134        if ($subject === '' && $recordPrefill['subject'] !== '') {
135            $subject = $recordPrefill['subject'];
136        }
137        if ($recordPrefill['body'] !== '') {
138            $replyBody = $recordPrefill['body'] . $replyBody;
139        }
140
141        if ($attachPdf && $this->pdfGenerator !== null && $this->pdfTemplateRepo !== null) {
142            $preloaded = $this->generatePreloadedPdfAttachment($module, $recordId);
143            if ($preloaded !== null) {
144                $preloadedAttachments[] = $preloaded;
145            }
146        }
147
148        return [$to, $subject, $replyBody, $preloadedAttachments, $bcc];
149    }
150
151    /**
152     * Enriches compose drawer with Google Calendar style invitation and preloads invite.ics.
153     *
154     * @param int                  $recordId Calendar event record ID.
155     * @param array<string, mixed> $params   Request parameters.
156     * @param string               $to       Existing TO recipient.
157     * @param string               $subject  Existing subject.
158     * @param string               $replyBody Existing body.
159     * @param string               $bcc      Existing BCC recipients.
160     * @return array{0: string, 1: string, 2: string, 3: array<int, array<string, string>>, 4: string}
161     */
162    private function enrichWithCalendarInvite(
163        int $recordId,
164        array $params,
165        string $to,
166        string $subject,
167        string $replyBody,
168        string $bcc
169    ): array {
170        $record = $this->lookupCalendarEvent($recordId);
171        if ($record === null) {
172            return [$to, $subject, $replyBody, [], $bcc];
173        }
174
175        $attendees = $this->extractEventAttendees($record['attendees'] ?? null);
176        $organizer = $this->resolveEventOrganizer($record);
177
178        // Put all event attendees into BCC (UDW)
179        $uniqueBcc = $this->collectAttendeeEmails($attendees);
180        if ($uniqueBcc !== []) {
181            $bcc = implode(', ', $uniqueBcc);
182        }
183
184        // Set TO recipient to organizer so the required TO field is valid
185        if ($to === '') {
186            $to = $organizer['email'] !== '' ? $organizer['email'] : 'attendees@ammonly.com';
187        }
188
189        $startDate = (string) ($record['start_date'] ?? 'now');
190        $endDate = (string) ($record['end_date'] ?? '+1 hour');
191        $isAllDay = ((int) ($record['is_all_day'] ?? 0)) === 1;
192        $lang = strtolower((string) ($params['lang'] ?? 'pl'));
193        if (!in_array($lang, ['pl', 'en'], true)) {
194            $lang = 'pl';
195        }
196
197        $formattedDate = $this->formatCalendarDate($startDate, $endDate, $isAllDay, $lang);
198        $eventTitle = trim((string) ($record['subject'] ?? 'Spotkanie'));
199
200        if ($subject === '') {
201            $subject = $lang === 'pl'
202                ? sprintf('Zaproszenie: %s â€“ %s', $eventTitle, $formattedDate)
203                : sprintf('Invitation: %s â€“ %s', $eventTitle, $formattedDate);
204        }
205
206        $replyBody = $this->renderCalendarInvitationBody(
207            $record,
208            $eventTitle,
209            $formattedDate,
210            $startDate,
211            $endDate,
212            $organizer,
213            $attendees,
214            $lang
215        );
216
217        $preloadedAttachments = $this->generatePreloadedIcsAttachment($record, $attendees, $organizer);
218
219        return [$to, $subject, $replyBody, $preloadedAttachments, $bcc];
220    }
221
222    /**
223     * @param list<array{name: string, email: string, status: string}> $attendees
224     * @return list<string>
225     */
226    private function collectAttendeeEmails(array $attendees): array
227    {
228        $emails = [];
229        foreach ($attendees as $att) {
230            $email = trim((string) ($att['email'] ?? ''));
231            if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
232                $emails[] = $email;
233            }
234        }
235
236        return array_values(array_unique($emails));
237    }
238
239    /**
240     * @param array<string, mixed>                                     $record
241     * @param list<array{name: string, email: string, status: string}> $attendees
242     * @param array{name: string, email: string}                       $organizer
243     */
244    private function renderCalendarInvitationBody(
245        array $record,
246        string $eventTitle,
247        string $formattedDate,
248        string $startDate,
249        string $endDate,
250        array $organizer,
251        array $attendees,
252        string $lang
253    ): string {
254        $context = [
255            'subject'            => $eventTitle,
256            'location'           => (string) ($record['location'] ?? ''),
257            'meeting_url'        => (string) ($record['meeting_url'] ?? ''),
258            'description'        => (string) ($record['description'] ?? ''),
259            'organizer'          => $organizer,
260            'attendees'          => $attendees,
261            'lang'               => $lang,
262            'is_cancelled'       => false,
263            'is_updated'         => false,
264            'update_notice'      => null,
265            'start_iso'          => date('c', strtotime($startDate)),
266            'end_iso'            => date('c', strtotime($endDate)),
267            'formatted_date'     => $formattedDate,
268            'old_formatted_date' => null,
269            'target_email'       => '',
270            'rsvp_yes_url'       => '',
271            'rsvp_no_url'        => '',
272            'rsvp_maybe_url'     => '',
273            'rsvp_more_url'      => '',
274        ];
275
276        $tpl = $lang === 'pl'
277            ? 'mail/calendar/invitation_google_style_pl.twig'
278            : 'mail/calendar/invitation_google_style_en.twig';
279
280        try {
281            return $this->twig->render($tpl, $context);
282        } catch (Throwable) {
283            return sprintf(
284                '<p>Zaproszenie na spotkanie: <strong>%s</strong> (%s)</p>',
285                htmlspecialchars($eventTitle, ENT_QUOTES, 'UTF-8'),
286                htmlspecialchars($formattedDate, ENT_QUOTES, 'UTF-8')
287            );
288        }
289    }
290
291    /**
292     * @param array<string, mixed>                                     $record
293     * @param list<array{name: string, email: string, status: string}> $attendees
294     * @param array{name: string, email: string}                       $organizer
295     * @return array<int, array<string, string>>
296     */
297    private function generatePreloadedIcsAttachment(array $record, array $attendees, array $organizer): array
298    {
299        try {
300            $icsGenerator = new CalendarIcsGenerator();
301            $icsContent = $icsGenerator->generateRequest($record, $attendees, $organizer);
302            $token = bin2hex(random_bytes(16));
303
304            $staged = $this->stageFile(
305                $token,
306                'invite.ics',
307                'text/calendar; charset=UTF-8; method=REQUEST',
308                $icsContent
309            );
310
311            return [$staged];
312        } catch (Throwable) {
313            return [];
314        }
315    }
316
317    /**
318     * @return array<string, mixed>|null
319     */
320    private function lookupCalendarEvent(int $recordId): ?array
321    {
322        if ($this->pdo === null) {
323            return null;
324        }
325
326        $tables = ['c_mod_calendar_records', 'a_mod_calendar_records'];
327        foreach ($tables as $table) {
328            try {
329                $sql = "SELECT `id`, `c_uid`, `sequence`, `subject`, `start_date`, `end_date`, `is_all_day`, "
330                    . "`location`, `meeting_url`, `status`, `description`, `owner`, `created_by`, `attendees` "
331                    . "FROM `{$table}` WHERE `id` = :id LIMIT 1";
332                $stmt = $this->pdo->prepare($sql);
333                $stmt->execute([':id' => $recordId]);
334                $row = $stmt->fetch(PDO::FETCH_ASSOC);
335                if ($row !== false) {
336                    return $row;
337                }
338            } catch (Throwable) {
339                // Table might not exist in current profile, check next
340            }
341        }
342
343        return null;
344    }
345
346    /**
347     * @return list<array{name: string, email: string, status: string}>
348     */
349    private function extractEventAttendees(mixed $raw): array
350    {
351        if (is_string($raw) && trim($raw) !== '') {
352            $raw = json_decode($raw, true);
353        }
354
355        $candidates = [];
356        if (is_array($raw)) {
357            $candidates = isset($raw['attendees']) && is_array($raw['attendees'])
358                ? $raw['attendees']
359                : $raw;
360        }
361
362        $list = [];
363        foreach ($candidates as $item) {
364            if (!is_array($item)) {
365                continue;
366            }
367            $email = trim((string) ($item['email'] ?? ''));
368            if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
369                continue;
370            }
371            $name = trim((string) ($item['name'] ?? ''));
372            $status = strtolower(trim((string) ($item['status'] ?? 'needs-action')));
373
374            $list[] = [
375                'name'   => $name !== '' ? $name : $email,
376                'email'  => $email,
377                'status' => $status,
378            ];
379        }
380
381        return $list;
382    }
383
384    /**
385     * @param array<string, mixed> $record
386     * @return array{name: string, email: string}
387     */
388    private function resolveEventOrganizer(array $record): array
389    {
390        $userId = (int) ($record['owner'] ?? ($record['created_by'] ?? $this->currentUser->getId()));
391        if ($this->pdo !== null && $userId > 0) {
392            foreach (['c_mod_users_records', 'a_mod_users_records'] as $userTable) {
393                try {
394                    $sql = "SELECT `first_name`, `last_name`, `email` FROM `{$userTable}` WHERE `id` = :id LIMIT 1";
395                    $stmt = $this->pdo->prepare($sql);
396                    $stmt->execute([':id' => $userId]);
397                    $row = $stmt->fetch(PDO::FETCH_ASSOC);
398                    if ($row !== false) {
399                        $name = trim(($row['first_name'] ?? '') . ' ' . ($row['last_name'] ?? ''));
400                        return [
401                            'name'  => $name !== '' ? $name : (string) ($row['email'] ?? 'Ammonly User'),
402                            'email' => (string) ($row['email'] ?? ''),
403                        ];
404                    }
405                } catch (Throwable) {
406                    // Try next table
407                }
408            }
409        }
410
411        return [
412            'name'  => 'Ammonly Calendar',
413            'email' => '',
414        ];
415    }
416
417    private function formatCalendarDate(string $startDate, string $endDate, bool $isAllDay, string $lang): string
418    {
419        try {
420            $tz = new DateTimeZone('Europe/Warsaw');
421            $start = new DateTimeImmutable($startDate, $tz);
422            $end = new DateTimeImmutable($endDate, $tz);
423
424            if ($isAllDay) {
425                return $lang === 'pl'
426                    ? $start->format('d.m.Y') . ' (CaÅ‚y dzieÅ„)'
427                    : $start->format('M d, Y') . ' (All day)';
428            }
429
430            $sameDay = $start->format('Y-m-d') === $end->format('Y-m-d');
431            if ($sameDay) {
432                $fmt = $lang === 'pl' ? 'd.m.Y' : 'M d, Y';
433                return sprintf(
434                    '%s, %s â€“ %s (CEST)',
435                    $start->format($fmt),
436                    $start->format('H:i'),
437                    $end->format('H:i')
438                );
439            }
440
441            return sprintf('%s â€“ %s (CEST)', $start->format('d.m.Y H:i'), $end->format('d.m.Y H:i'));
442        } catch (Throwable) {
443            return "{$startDate} - {$endDate}";
444        }
445    }
446
447    /**
448     * Sends message drafted in compose drawer.
449     */
450    public function sendMessage(ServerRequestInterface $request): ResponseInterface
451    {
452        $userId = (int) $this->currentUser->getId();
453        $body = (array) $request->getParsedBody();
454        $mailboxId = (int) ($body['mailbox_id'] ?? 0);
455
456        $mailboxes = $this->authService->getUserMailboxes($userId);
457        $activeMbox = $this->findActiveMailbox($mailboxes, $mailboxId);
458
459        if ($activeMbox === null) {
460            $html = $this->twig->render(self::TEMPLATE_ALERT, [
461                'type'    => 'danger',
462                'message' => 'No valid sender mailbox selected.',
463            ]);
464            return $this->htmlResponse($html, 400);
465        }
466
467        $msg = $this->buildOutgoingMessage($mailboxId, $activeMbox, $body, $request);
468        $result = $this->senderService->sendMessage($userId, $msg);
469
470        if ($result->isSuccess) {
471            $this->saveOutboundEmailRecord($userId, $mailboxId, $activeMbox, $body, $result->messageId);
472            $html = $this->twig->render(self::TEMPLATE_ALERT, [
473                'type'    => 'success',
474                'message' => 'Message sent successfully!',
475            ]);
476            return $this->htmlResponse($html)->withHeader('HX-Trigger', 'mailMessageSent');
477        }
478
479        $html = $this->twig->render(self::TEMPLATE_ALERT, [
480            'type'    => 'danger',
481            'message' => 'Send failed: ' . $result->message,
482        ]);
483        return $this->htmlResponse($html, 500);
484    }
485
486    /**
487     * @param array<int, ClientMailbox> $mailboxes
488     */
489    private function resolveDefaultMailboxId(array $mailboxes): int
490    {
491        foreach ($mailboxes as $m) {
492            if ($m->isDefault) {
493                return $m->id;
494            }
495        }
496
497        return !empty($mailboxes) ? $mailboxes[0]->id : 0;
498    }
499
500    /**
501     * @param array<int, ClientMailbox> $mailboxes
502     * @param array<string, mixed> $params
503     * @return array{0: string, 1: string, 2: string, 3: string, 4: string, 5: ?string}
504     */
505    private function resolveComposePrefillData(
506        array $mailboxes,
507        int $mailboxId,
508        int $userId,
509        array $params
510    ): array {
511        if ($mailboxId > 0) {
512            $existing = $this->resolveExistingMessagePrefill($mailboxId, $userId, $params);
513            if ($existing !== null) {
514                return $existing;
515            }
516        }
517
518        return $this->resolveCustomComposePrefill($mailboxes, $mailboxId, $params);
519    }
520
521    /**
522     * @param array<string, mixed> $params
523     * @return array{0: string, 1: string, 2: string, 3: string, 4: string, 5: ?string}|null
524     */
525    private function resolveExistingMessagePrefill(int $mailboxId, int $userId, array $params): ?array
526    {
527        $replyAllUid = (string) ($params['reply_all_uid'] ?? '');
528        if ($replyAllUid !== '') {
529            [$subject, $to, $cc, $replyBody, $inReplyTo] = $this->resolveReplyAllPrefill(
530                $mailboxId,
531                $userId,
532                $params,
533                $replyAllUid
534            );
535            return [$subject, $to, $cc, '', $replyBody, $inReplyTo];
536        }
537
538        return $this->resolveReplyOrForwardPrefill($mailboxId, $userId, $params);
539    }
540
541    /**
542     * @param array<string, mixed> $params
543     * @return array{0: string, 1: string, 2: string, 3: string, 4: string, 5: ?string}|null
544     */
545    private function resolveReplyOrForwardPrefill(int $mailboxId, int $userId, array $params): ?array
546    {
547        $replyUid = (string) ($params['reply_uid'] ?? '');
548        if ($replyUid !== '') {
549            [$subject, $to, $replyBody, $inReplyTo] = $this->resolveReplyPrefill(
550                $mailboxId,
551                $userId,
552                $params,
553                $replyUid
554            );
555            return [$subject, $to, '', '', $replyBody, $inReplyTo];
556        }
557
558        $forwardUid = (string) ($params['forward_uid'] ?? '');
559        if ($forwardUid !== '') {
560            [$subject, $to, $replyBody] = $this->resolveForwardPrefill(
561                $mailboxId,
562                $userId,
563                $params,
564                $forwardUid
565            );
566            return [$subject, $to, '', '', $replyBody, null];
567        }
568
569        return null;
570    }
571
572    /**
573     * @param array<int, ClientMailbox> $mailboxes
574     * @param array<string, mixed> $params
575     * @return array{0: string, 1: string, 2: string, 3: string, 4: string, 5: ?string}
576     */
577    private function resolveCustomComposePrefill(
578        array $mailboxes,
579        int $mailboxId,
580        array $params
581    ): array {
582        $subject = (string) ($params['subject'] ?? '');
583        $to = (string) ($params['to'] ?? '');
584        $customBody = (string) ($params['body'] ?? '');
585        $inReplyTo = isset($params['in_reply_to']) && $params['in_reply_to'] !== ''
586            ? (string) $params['in_reply_to']
587            : null;
588        $signature = $this->resolveSignaturePrefill($mailboxes, $mailboxId);
589        $replyBody = $customBody !== ''
590            ? nl2br(htmlspecialchars($customBody, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'))
591                . '<br><br>' . $signature
592            : $signature;
593
594        return [$subject, $to, '', '', $replyBody, $inReplyTo];
595    }
596
597    /**
598     * Resolves prefilled subject, recipient, and quoted body for replies.
599     *
600     * @param array<string, mixed> $params Query parameters.
601     * @return array{0: string, 1: string, 2: string, 3: ?string}
602     */
603    private function resolveReplyPrefill(int $mailboxId, int $userId, array $params, string $replyUid): array
604    {
605        try {
606            $folder = trim((string) ($params['folder'] ?? ''));
607            if ($folder === '') {
608                $folder = 'INBOX';
609            }
610            $detail = $this->messageService->getMessageDetail($mailboxId, $userId, $folder, $replyUid, false);
611            $subject = str_starts_with(strtoupper($detail->summary->subject), 'RE:')
612                ? $detail->summary->subject
613                : 'Re: ' . $detail->summary->subject;
614            $to = $detail->summary->fromEmail;
615            $replyBody = sprintf(
616                "\n\n--- On %s, %s wrote: ---\n%s",
617                $detail->summary->date,
618                $detail->summary->fromName,
619                $detail->textBody
620            );
621            return [$subject, $to, $replyBody, $detail->summary->messageId];
622        } catch (Throwable) {
623            return ['', '', '', null];
624        }
625    }
626
627    /**
628     * Resolves prefilled fields for Reply All (including CC).
629     *
630     * @param array<string, mixed> $params
631     * @return array{0: string, 1: string, 2: string, 3: string, 4: ?string}
632     */
633    private function resolveReplyAllPrefill(int $mailboxId, int $userId, array $params, string $replyAllUid): array
634    {
635        try {
636            $folder = trim((string) ($params['folder'] ?? ''));
637            if ($folder === '') {
638                $folder = 'INBOX';
639            }
640            $detail = $this->messageService->getMessageDetail($mailboxId, $userId, $folder, $replyAllUid, false);
641            $subject = str_starts_with(strtoupper($detail->summary->subject), 'RE:')
642                ? $detail->summary->subject
643                : 'Re: ' . $detail->summary->subject;
644            $to = $detail->summary->fromEmail;
645            $ccList = implode(', ', $detail->summary->to);
646            $replyBody = sprintf(
647                "\n\n--- On %s, %s wrote: ---\n%s",
648                $detail->summary->date,
649                $detail->summary->fromName,
650                $detail->textBody
651            );
652            return [$subject, $to, $ccList, $replyBody, $detail->summary->messageId];
653        } catch (Throwable) {
654            return ['', '', '', '', null];
655        }
656    }
657
658    /**
659     * Resolves prefilled fields for Forward.
660     *
661     * @param array<string, mixed> $params
662     * @return array{0: string, 1: string, 2: string}
663     */
664    private function resolveForwardPrefill(int $mailboxId, int $userId, array $params, string $forwardUid): array
665    {
666        try {
667            $folder = trim((string) ($params['folder'] ?? ''));
668            if ($folder === '') {
669                $folder = 'INBOX';
670            }
671            $detail = $this->messageService->getMessageDetail($mailboxId, $userId, $folder, $forwardUid, false);
672            $subject = str_starts_with(strtoupper($detail->summary->subject), 'FWD:')
673                ? $detail->summary->subject
674                : 'Fwd: ' . $detail->summary->subject;
675            $replyBody = sprintf(
676                "\n\n-------- Forwarded Message --------\nSubject: %s\nDate: %s\nFrom: %s\n\n%s",
677                $detail->summary->subject,
678                $detail->summary->date,
679                $detail->summary->fromEmail,
680                $detail->textBody
681            );
682            return [$subject, '', $replyBody];
683        } catch (Throwable) {
684            return ['', '', ''];
685        }
686    }
687
688    /**
689     * Resolves signature HTML for new compositions.
690     *
691     * @param array<ClientMailbox> $mailboxes Available mailboxes.
692     * @param int $mailboxId Active mailbox ID.
693     * @return string Signature HTML string or empty string.
694     */
695    private function resolveSignaturePrefill(array $mailboxes, int $mailboxId): string
696    {
697        foreach ($mailboxes as $m) {
698            if ($m->id === $mailboxId && $m->signatureHtml !== null && $m->signatureHtml !== '') {
699                return '<br><br>' . $m->signatureHtml;
700            }
701        }
702        return '';
703    }
704
705    /**
706     * @param array<int, ClientMailbox> $mailboxes
707     */
708    private function findActiveMailbox(array $mailboxes, int $mailboxId): ?ClientMailbox
709    {
710        foreach ($mailboxes as $m) {
711            if ($m->id === $mailboxId) {
712                return $m;
713            }
714        }
715
716        return null;
717    }
718
719    /**
720     * @param array<string, mixed> $body
721     */
722    private function buildOutgoingMessage(
723        int $mailboxId,
724        ClientMailbox $activeMbox,
725        array $body,
726        ServerRequestInterface $request
727    ): MailOutgoingMessageDto {
728        $toRaw = (string) ($body['to'] ?? '');
729        $ccRaw = (string) ($body['cc'] ?? '');
730        $bccRaw = (string) ($body['bcc'] ?? '');
731        $htmlBody = (string) ($body['body_html'] ?? '');
732        $textBody = (string) ($body['body_text'] ?? '');
733        $inReplyTo = !empty($body['in_reply_to']) ? (string) $body['in_reply_to'] : null;
734
735        $toAddresses = array_values(array_filter(array_map('trim', explode(',', $toRaw))));
736        $ccAddresses = $ccRaw !== '' ? array_values(array_filter(array_map('trim', explode(',', $ccRaw)))) : [];
737        $bccAddresses = $bccRaw !== '' ? array_values(array_filter(array_map('trim', explode(',', $bccRaw)))) : [];
738        $uploadedAttachments = $this->extractUploadedAttachments($request);
739        $preloadedAttachments = $this->extractPreloadedAttachments($body);
740        $attachments = array_merge($uploadedAttachments, $preloadedAttachments);
741
742        return new MailOutgoingMessageDto(
743            mailboxId: $mailboxId,
744            fromEmail: $activeMbox->email,
745            fromName: $activeMbox->fromName,
746            to: $toAddresses,
747            cc: $ccAddresses,
748            bcc: $bccAddresses,
749            subject: (string) ($body['subject'] ?? ''),
750            htmlBody: $htmlBody,
751            textBody: $textBody !== '' ? $textBody : strip_tags($htmlBody),
752            attachments: $attachments,
753            replyToMessageId: $inReplyTo
754        );
755    }
756
757    /**
758     * Extracts preloaded staged attachments submitted via hidden token inputs.
759     *
760     * @param array<string, mixed> $body Form payload.
761     * @return array<int, array<string, string>> Staged attachment records.
762     */
763    private function extractPreloadedAttachments(array $body): array
764    {
765        $tokens = (array) ($body['preloaded_attachment_tokens'] ?? []);
766        $attachments = [];
767        $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ammonly_mail_staging';
768
769        foreach ($tokens as $token) {
770            if (!is_string($token) || !preg_match('/^[a-f0-9]{32}$/', $token)) {
771                continue;
772            }
773
774            $metaFile = $tempDir . DIRECTORY_SEPARATOR . $token . '.meta';
775            $binFile = $tempDir . DIRECTORY_SEPARATOR . $token . '.bin';
776
777            if (is_file($metaFile) && is_file($binFile)) {
778                $metaJson = (string) file_get_contents($metaFile);
779                $meta = json_decode($metaJson, true);
780                if (is_array($meta)) {
781                    $attachments[] = [
782                        'filename' => (string) ($meta['filename'] ?? 'document.pdf'),
783                        'content'  => (string) file_get_contents($binFile),
784                        'mimeType' => (string) ($meta['mimeType'] ?? 'application/pdf'),
785                    ];
786                }
787                @unlink($metaFile);
788                @unlink($binFile);
789            }
790        }
791
792        return $attachments;
793    }
794
795    /**
796     * Resolves counterparty contact email, subject, and document label for module record.
797     *
798     * @param string $module   Target module name (e.g. quotes, orders).
799     * @param int    $recordId Target record ID.
800     * @return array{to: string, subject: string, body: string}
801     */
802    private function resolveModuleRecordPrefill(string $module, int $recordId): array
803    {
804        if ($this->pdo === null) {
805            return ['to' => '', 'subject' => '', 'body' => ''];
806        }
807
808        try {
809            $resolver = new TemplateVariableResolver($this->pdo);
810            $context = $resolver->resolveContext($module, $recordId);
811            $record = $context['record'] ?? [];
812
813            $to = (string) ($record['contact_email'] ?? $record['company_email'] ?? '');
814            $companyName = (string) ($record['company_name'] ?? '');
815
816            $docNumber = match ($module) {
817                'quotes'   => (string) ($record['quote_number'] ?? '#' . $recordId),
818                'orders'   => (string) ($record['order_number'] ?? '#' . $recordId),
819                'invoices' => (string) ($record['invoice_number'] ?? '#' . $recordId),
820                default    => '#' . $recordId,
821            };
822
823            $docTypeLabel = match ($module) {
824                'quotes'   => 'Oferta ' . $docNumber,
825                'orders'   => 'Zamówienie ' . $docNumber,
826                'invoices' => 'Faktura ' . $docNumber,
827                default    => ucfirst($module) . ' #' . $recordId,
828            };
829
830            $subject = $companyName !== '' ? "{$docTypeLabel} - {$companyName}" : $docTypeLabel;
831
832            $greeting = $companyName !== ''
833                ? "DzieÅ„ dobry,<br><br>W załączeniu przesyÅ‚am dokument w formacie PDF: "
834                    . "<strong>{$docTypeLabel}</strong>.<br><br>"
835                : "DzieÅ„ dobry,<br><br>W załączeniu przesyÅ‚am dokument w formacie PDF.<br><br>";
836
837            return [
838                'to'      => $to,
839                'subject' => $subject,
840                'body'    => $greeting,
841            ];
842        } catch (Throwable) {
843            return ['to' => '', 'subject' => '', 'body' => ''];
844        }
845    }
846
847    /**
848     * Generates a PDF file for the record and stores it in temporary staging storage.
849     *
850     * @param string $module   Target module name.
851     * @param int    $recordId Target record ID.
852     * @return array<string, string>|null Attachment descriptor or null on failure.
853     */
854    private function generatePreloadedPdfAttachment(string $module, int $recordId): ?array
855    {
856        $template = $this->pdfGenerator !== null ? $this->findFirstActiveTemplate($module) : null;
857        if ($template === null) {
858            return null;
859        }
860
861        try {
862            $pdf = $this->pdfGenerator->generateForRecord($template->id, $module, $recordId);
863            $token = bin2hex(random_bytes(16));
864            $filename = (string) ($pdf['filename'] ?? "{$module}_{$recordId}.pdf");
865            $mimeType = (string) ($pdf['mime_type'] ?? 'application/pdf');
866            $content = (string) ($pdf['content'] ?? '');
867
868            return $this->stageFile($token, $filename, $mimeType, $content);
869        } catch (Throwable) {
870            return null;
871        }
872    }
873
874    private function findFirstActiveTemplate(string $module): ?object
875    {
876        if ($this->pdfTemplateRepo === null) {
877            return null;
878        }
879
880        $templates = $this->pdfTemplateRepo->findAllActiveByModule($module);
881
882        return $templates !== [] ? $templates[0] : null;
883    }
884
885    /**
886     * @return array{token: string, filename: string, size_formatted: string}
887     */
888    private function stageFile(string $token, string $filename, string $mimeType, string $content): array
889    {
890        $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ammonly_mail_staging';
891        if (!is_dir($tempDir)) {
892            @mkdir($tempDir, 0700, true);
893        }
894
895        $metaFile = $tempDir . DIRECTORY_SEPARATOR . $token . '.meta';
896        $binFile = $tempDir . DIRECTORY_SEPARATOR . $token . '.bin';
897
898        $meta = [
899            'filename' => $filename,
900            'mimeType' => $mimeType,
901        ];
902
903        file_put_contents($metaFile, json_encode($meta, JSON_THROW_ON_ERROR));
904        file_put_contents($binFile, $content);
905
906        $sizeBytes = strlen($content);
907        $sizeFormatted = $sizeBytes >= 1048576
908            ? round($sizeBytes / 1048576, 1) . ' MB'
909            : round($sizeBytes / 1024, 1) . ' KB';
910
911        return [
912            'token'          => $token,
913            'filename'       => $filename,
914            'size_formatted' => $sizeFormatted,
915        ];
916    }
917
918    /**
919     * @return array<int, array<string, string>>
920     */
921    private function extractUploadedAttachments(ServerRequestInterface $request): array
922    {
923        $attachments = [];
924        $uploadedFiles = $request->getUploadedFiles();
925        $files = $uploadedFiles['attachments'] ?? [];
926        if (!is_array($files)) {
927            $files = [$files];
928        }
929
930        foreach ($files as $file) {
931            if ($file instanceof UploadedFileInterface && $file->getError() === UPLOAD_ERR_OK) {
932                $attachments[] = [
933                    'filename' => $file->getClientFilename() ?? 'attachment.bin',
934                    'content'  => (string) $file->getStream(),
935                    'mimeType' => $file->getClientMediaType() ?? 'application/octet-stream',
936                ];
937            }
938        }
939
940        return $attachments;
941    }
942
943    /**
944     * @param array<string, mixed> $body
945     */
946    private function saveOutboundEmailRecord(
947        int $userId,
948        int $mailboxId,
949        ClientMailbox $activeMbox,
950        array $body,
951        ?string $messageId
952    ): void {
953        $toRaw = (string) ($body['to'] ?? '');
954        $toAddresses = array_values(array_filter(array_map('trim', explode(',', $toRaw))));
955        $htmlBody = (string) ($body['body_html'] ?? '');
956        $textBody = (string) ($body['body_text'] ?? '');
957        $ticketId = (int) ($body['ticket_id'] ?? 0);
958
959        $this->mailRepo->recordOutboundEmail([
960            'subject'    => (string) ($body['subject'] ?? ''),
961            'from_email' => $activeMbox->email,
962            'from_name'  => $activeMbox->fromName,
963            'to_email'   => implode(', ', $toAddresses),
964            'body_html'  => $htmlBody !== '' ? $htmlBody : nl2br(htmlspecialchars($textBody)),
965            'body_text'  => $textBody !== '' ? $textBody : strip_tags($htmlBody),
966            'message_id' => $messageId,
967            'mailbox_id' => $mailboxId,
968            'ticket_id'  => $ticketId > 0 ? $ticketId : null,
969            'owner'      => $userId,
970            'created_by' => $userId,
971        ]);
972    }
973
974    private function htmlResponse(string $html, int $status = 200): ResponseInterface
975    {
976        $response = $this->responseFactory->createResponse($status)
977            ->withHeader('Content-Type', 'text/html; charset=utf-8');
978        $response->getBody()->write($html);
979        return $response;
980    }
981}