Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.07% covered (success)
98.07%
203 / 207
76.47% covered (warning)
76.47%
13 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmailTemplateCompilerService
98.06% covered (success)
98.06%
202 / 206
76.47% covered (warning)
76.47%
13 / 17
52
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
 getAvailableBlocks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 compileBlocks
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 extractRawBlocks
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
6.29
 parseAndSortBlocks
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 compileSingleBlock
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
12
 renderPreheader
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 renderHeaderLogo
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 renderHeading
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 renderParagraph
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 renderCtaButton
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 renderCalloutBox
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
2.00
 renderDivider
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 renderKeyValueList
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 renderSpacer
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 renderFooter
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 wrapInEmailBoilerplate
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
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;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Template\Application\Service\CssInlinerService;
12use App\Modules\Mail\Domain\Model\EmailBlock;
13use App\Modules\Mail\Domain\Service\EmailBlockRegistry;
14use Throwable;
15
16/**
17 * Dedicated Email Block AST Compiler and Responsive HTML Engine.
18 *
19 * Compiles modular email blocks (email_*) into bulletproof, table-based, mobile-friendly
20 * HTML email documents with automated CSS inlining for cross-client compatibility.
21 *
22 * @package App\Modules\Mail\Application\Service
23 */
24final class EmailTemplateCompilerService implements EmailTemplateCompilerServiceInterface
25{
26    private const string FONT_FAMILY =
27        "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
28
29    private CssInlinerService $inliner;
30
31    /**
32     * EmailTemplateCompilerService constructor.
33     *
34     * @param CssInlinerService|null $inliner Optional CSS inliner.
35     */
36    public function __construct(?CssInlinerService $inliner = null)
37    {
38        $this->inliner = $inliner ?? new CssInlinerService();
39    }
40
41    /** {@inheritdoc} */
42    public function getAvailableBlocks(): array
43    {
44        return EmailBlockRegistry::getAll();
45    }
46
47    /** {@inheritdoc} */
48    public function compileBlocks(array|string $blocksData, ?string $cssStyles = null): string
49    {
50        $tree = is_string($blocksData)
51            ? (json_decode($blocksData, true) ?? [])
52            : $blocksData;
53
54        $rawBlocks = $this->extractRawBlocks($tree);
55        if ($rawBlocks === []) {
56            return '';
57        }
58
59        $blocks = $this->parseAndSortBlocks($rawBlocks);
60        $innerHtml = '';
61        foreach ($blocks as $b) {
62            $innerHtml .= $this->compileSingleBlock($b);
63        }
64
65        $containerBg = htmlspecialchars((string)($tree['container_bg'] ?? '#ffffff'), ENT_QUOTES, 'UTF-8');
66        $bodyBg = htmlspecialchars((string)($tree['body_bg'] ?? '#f1f5f9'), ENT_QUOTES, 'UTF-8');
67
68        $document = $this->wrapInEmailBoilerplate($innerHtml, $bodyBg, $containerBg, $cssStyles);
69
70        $result = $document;
71        try {
72            $result = $this->inliner->inline($document);
73        } catch (Throwable) {
74            // Fallback to raw document if CSS inlining fails
75        }
76
77        return $result;
78    }
79
80    /**
81     * Extracts raw block array from deserialized tree data.
82     *
83     * @param mixed $tree Deserialized tree dictionary.
84     * @return array<int, mixed> List of raw block dictionaries.
85     */
86    private function extractRawBlocks(mixed $tree): array
87    {
88        if (!is_array($tree)) {
89            return [];
90        }
91
92        return match (true) {
93            isset($tree['blocks']) && is_array($tree['blocks']) => $tree['blocks'],
94            array_is_list($tree)                                 => $tree,
95            default                                              => [],
96        };
97    }
98
99    /**
100     * Parses raw block data into ordered list of EmailBlock aggregates.
101     *
102     * @param array<int, mixed> $rawBlocks
103     * @return array<int, EmailBlock>
104     */
105    private function parseAndSortBlocks(array $rawBlocks): array
106    {
107        $blocks = [];
108        foreach ($rawBlocks as $idx => $item) {
109            if (is_array($item)) {
110                $block = EmailBlock::fromArray($item);
111                if ($block->sort === 0) {
112                    $block = new EmailBlock(
113                        $block->id,
114                        $block->type,
115                        $idx * 10,
116                        $block->props,
117                        $block->children,
118                        $block->styles
119                    );
120                }
121                $blocks[] = $block;
122            }
123        }
124
125        usort($blocks, static fn (EmailBlock $a, EmailBlock $b): int => $a->sort <=> $b->sort);
126        return $blocks;
127    }
128
129    /**
130     * Compiles an individual email block to HTML snippet.
131     */
132    private function compileSingleBlock(EmailBlock $block): string
133    {
134        return match ($block->type) {
135            'email_preheader'          => $this->renderPreheader($block),
136            'email_header_logo'        => $this->renderHeaderLogo($block),
137            'email_heading'            => $this->renderHeading($block),
138            'email_paragraph'          => $this->renderParagraph($block),
139            'email_cta_button'         => $this->renderCtaButton($block),
140            'email_callout_box'        => $this->renderCalloutBox($block),
141            'email_divider'            => $this->renderDivider($block),
142            'email_key_value_list'     => $this->renderKeyValueList($block),
143            'email_spacer'             => $this->renderSpacer($block),
144            'email_footer_unsubscribe' => $this->renderFooter($block),
145            default                    => $this->renderParagraph($block),
146        };
147    }
148
149    private function renderPreheader(EmailBlock $block): string
150    {
151        $text = htmlspecialchars((string)($block->props['text'] ?? ''), ENT_QUOTES, 'UTF-8');
152        if ($text === '') {
153            return '';
154        }
155
156        return '<div style="display:none;font-size:1px;line-height:1px;max-height:0;max-width:0;'
157            . 'opacity:0;overflow:hidden;mso-hide:all;">'
158            . $text
159            . '</div>' . "\n";
160    }
161
162    private function renderHeaderLogo(EmailBlock $block): string
163    {
164        $appName = htmlspecialchars((string)($block->props['app_name'] ?? 'Ammonly'), ENT_QUOTES, 'UTF-8');
165        $logoUrl = htmlspecialchars((string)($block->props['logo_url'] ?? ''), ENT_QUOTES, 'UTF-8');
166        $align = htmlspecialchars((string)($block->props['align'] ?? 'center'), ENT_QUOTES, 'UTF-8');
167
168        $content = $logoUrl !== ''
169            ? sprintf('<img src="%s" alt="%s" style="max-height:40px;height:auto;display:inline-block;" />',
170                $logoUrl, $appName)
171            : sprintf('<span style="font-family:%s;font-size:20px;font-weight:700;color:#0f172a;">%s</span>',
172                self::FONT_FAMILY, $appName);
173
174        return sprintf(
175            '<table role="presentation" width="100%%" border="0" cellpadding="0" cellspacing="0" '
176            . 'style="margin-bottom:24px;"><tr><td align="%s" style="padding:16px 0;">%s</td></tr></table>' . "\n",
177            $align,
178            $content
179        );
180    }
181
182    private function renderHeading(EmailBlock $block): string
183    {
184        $text = (string)($block->props['text'] ?? 'Nagłówek');
185        $color = htmlspecialchars((string)($block->props['color'] ?? '#0f172a'), ENT_QUOTES, 'UTF-8');
186        $align = htmlspecialchars((string)($block->props['align'] ?? 'left'), ENT_QUOTES, 'UTF-8');
187        $level = (string)($block->props['level'] ?? 'h2');
188        $fontSize = match ($level) {
189            'h1' => '26px',
190            'h3' => '18px',
191            default => '22px',
192        };
193
194        return sprintf(
195            '<table role="presentation" width="100%%" border="0" cellpadding="0" cellspacing="0"><tr><td '
196            . 'align="%s" style="padding:0 0 12px 0;"><h2 style="font-family:%s;font-size:%s;font-weight:700;'
197            . 'line-height:1.3;color:%s;margin:0;">%s</h2></td></tr></table>' . "\n",
198            $align,
199            self::FONT_FAMILY,
200            $fontSize,
201            $color,
202            $text
203        );
204    }
205
206    private function renderParagraph(EmailBlock $block): string
207    {
208        $content = (string)($block->props['content'] ?? '');
209        $color = htmlspecialchars((string)($block->props['color'] ?? '#334155'), ENT_QUOTES, 'UTF-8');
210        $align = htmlspecialchars((string)($block->props['align'] ?? 'left'), ENT_QUOTES, 'UTF-8');
211
212        return sprintf(
213            '<table role="presentation" width="100%%" border="0" cellpadding="0" cellspacing="0"><tr><td '
214            . 'align="%s" style="padding:0 0 16px 0;font-family:%s;font-size:15px;line-height:1.6;'
215            . 'color:%s;">%s</td></tr></table>' . "\n",
216            $align,
217            self::FONT_FAMILY,
218            $color,
219            $content
220        );
221    }
222
223    private function renderCtaButton(EmailBlock $block): string
224    {
225        $text = htmlspecialchars((string)($block->props['text'] ?? 'Zresetuj hasÅ‚o'), ENT_QUOTES, 'UTF-8');
226        $url = (string)($block->props['url'] ?? '{{ reset_url }}');
227        $align = htmlspecialchars((string)($block->props['align'] ?? 'center'), ENT_QUOTES, 'UTF-8');
228        $bg = htmlspecialchars((string)($block->props['background_color'] ?? '#206bc4'), ENT_QUOTES, 'UTF-8');
229        $color = htmlspecialchars((string)($block->props['text_color'] ?? '#ffffff'), ENT_QUOTES, 'UTF-8');
230        $radius = htmlspecialchars((string)($block->props['border_radius'] ?? '6px'), ENT_QUOTES, 'UTF-8');
231
232        return sprintf(
233            '<table role="presentation" width="100%%" border="0" cellpadding="0" cellspacing="0" '
234            . 'style="margin:20px 0;"><tr><td align="%s"><table role="presentation" border="0" cellpadding="0" '
235            . 'cellspacing="0"><tr><td align="center" bgcolor="%s" style="border-radius:%s;">'
236            . '<a href="%s" target="_blank" style="display:inline-block;padding:12px 28px;font-family:%s;'
237            . 'font-size:15px;font-weight:600;color:%s;text-decoration:none;border-radius:%s;">%s</a>'
238            . '</td></tr></table></td></tr></table>' . "\n",
239            $align,
240            $bg,
241            $radius,
242            $url,
243            self::FONT_FAMILY,
244            $color,
245            $radius,
246            $text
247        );
248    }
249
250    private function renderCalloutBox(EmailBlock $block): string
251    {
252        $title = htmlspecialchars((string)($block->props['title'] ?? ''), ENT_QUOTES, 'UTF-8');
253        $content = (string)($block->props['content'] ?? '');
254        $bg = htmlspecialchars((string)($block->props['background_color'] ?? '#f0f6fc'), ENT_QUOTES, 'UTF-8');
255        $border = htmlspecialchars((string)($block->props['border_color'] ?? '#206bc4'), ENT_QUOTES, 'UTF-8');
256
257        $titleHtml = $title !== ''
258            ? sprintf('<div style="font-weight:700;margin-bottom:6px;color:#0f172a;">%s</div>', $title)
259            : '';
260
261        return sprintf(
262            '<table role="presentation" width="100%%" border="0" cellpadding="0" cellspacing="0" '
263            . 'style="margin:16px 0;"><tr><td bgcolor="%s" style="padding:14px 18px;border-left:4px solid %s;'
264            . 'border-radius:4px;font-family:%s;font-size:14px;line-height:1.5;color:#334155;">%s%s</td></tr>'
265            . '</table>' . "\n",
266            $bg,
267            $border,
268            self::FONT_FAMILY,
269            $titleHtml,
270            $content
271        );
272    }
273
274    private function renderDivider(EmailBlock $block): string
275    {
276        $color = htmlspecialchars((string)($block->props['color'] ?? '#e2e8f0'), ENT_QUOTES, 'UTF-8');
277        $margin = htmlspecialchars((string)($block->props['margin'] ?? '20px'), ENT_QUOTES, 'UTF-8');
278
279        return sprintf(
280            '<table role="presentation" width="100%%" border="0" cellpadding="0" cellspacing="0"><tr><td '
281            . 'style="padding:%s 0;"><div style="height:1px;background-color:%s;font-size:0;line-height:0;">'
282            . '</div></td></tr></table>' . "\n",
283            $margin,
284            $color
285        );
286    }
287
288    /**
289     * @param array<int, array<string, string>>|mixed $items
290     */
291    private function renderKeyValueList(EmailBlock $block): string
292    {
293        $items = $block->props['items'] ?? [];
294        if (!is_array($items) || $items === []) {
295            return '';
296        }
297
298        $rows = '';
299        foreach ($items as $item) {
300            if (is_array($item)) {
301                $lbl = htmlspecialchars((string)($item['label'] ?? ''), ENT_QUOTES, 'UTF-8');
302                $val = (string)($item['value'] ?? '');
303                $rows .= sprintf(
304                    '<tr><td width="35%%" style="padding:6px 0;font-weight:600;color:#64748b;">%s:</td>'
305                    . '<td width="65%%" style="padding:6px 0;color:#0f172a;">%s</td></tr>',
306                    $lbl,
307                    $val
308                );
309            }
310        }
311
312        return sprintf(
313            '<table role="presentation" width="100%%" border="0" cellpadding="0" cellspacing="0" '
314            . 'style="margin:12px 0;font-family:%s;font-size:14px;">%s</table>' . "\n",
315            self::FONT_FAMILY,
316            $rows
317        );
318    }
319
320    private function renderSpacer(EmailBlock $block): string
321    {
322        $height = (int)($block->props['height'] ?? 24);
323        if ($height < 4) {
324            $height = 24;
325        }
326
327        return sprintf(
328            '<table role="presentation" width="100%%" border="0" cellpadding="0" cellspacing="0"><tr><td '
329            . 'height="%d" style="font-size:0;line-height:0;">&nbsp;</td></tr></table>' . "\n",
330            $height
331        );
332    }
333
334    private function renderFooter(EmailBlock $block): string
335    {
336        $company = htmlspecialchars((string)($block->props['company_name'] ?? 'Ammonly'), ENT_QUOTES, 'UTF-8');
337        $notice = (string)($block->props['notice_text'] ?? '');
338
339        return sprintf(
340            '<table role="presentation" width="100%%" border="0" cellpadding="0" cellspacing="0" '
341            . 'style="margin-top:24px;border-top:1px solid #e2e8f0;"><tr><td align="center" style="padding:20px 0 0 0;'
342            . 'font-family:%s;font-size:12px;line-height:1.5;color:#64748b;"><p style="margin:0 0 6px 0;'
343            . 'font-weight:600;">%s</p><p style="margin:0;">%s</p></td></tr></table>' . "\n",
344            self::FONT_FAMILY,
345            $company,
346            $notice
347        );
348    }
349
350    /**
351     * Wraps compiled blocks inside clean responsive HTML email skeleton.
352     */
353    private function wrapInEmailBoilerplate(
354        string $innerHtml,
355        string $bodyBg,
356        string $containerBg,
357        ?string $customCss
358    ): string {
359        $styles = $customCss ? "<style>{$customCss}</style>" : '';
360
361        $bodyStyle = "margin:0;padding:0;background-color:{$bodyBg};font-family:"
362            . self::FONT_FAMILY . ";-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;";
363        $tableStyle = "max-width:600px;background-color:{$containerBg};border-radius:8px;"
364            . "box-shadow:0 1px 3px rgba(0,0,0,0.05);overflow:hidden;";
365
366        return <<<HTML
367<!DOCTYPE html>
368<html lang="pl">
369<head>
370    <meta charset="utf-8">
371    <meta name="viewport" content="width=device-width, initial-scale=1.0">
372    <meta http-equiv="X-UA-Compatible" content="IE=edge">
373    <title>Email</title>
374    {$styles}
375</head>
376<body style="{$bodyStyle}">
377    <table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"
378           style="background-color:{$bodyBg};">
379        <tr>
380            <td align="center" style="padding:30px 15px;">
381                <!--[if (gte mso 9)|(IE)]>
382                <table role="presentation" width="600" align="center" border="0" cellpadding="0" cellspacing="0">
383                <tr><td>
384                <![endif]-->
385                <table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"
386                       style="{$tableStyle}">
387                    <tr>
388                        <td style="padding:32px 32px 24px 32px;">
389                            {$innerHtml}
390                        </td>
391                    </tr>
392                </table>
393                <!--[if (gte mso 9)|(IE)]>
394                </td></tr></table>
395                <![endif]-->
396            </td>
397        </tr>
398    </table>
399</body>
400</html>
401HTML;
402    }
403}