email-editor-core 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +438 -0
  2. package/dist/index.d.ts +127 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +260 -0
  5. package/dist/renderer/blocks/button.d.ts +18 -0
  6. package/dist/renderer/blocks/button.d.ts.map +1 -0
  7. package/dist/renderer/blocks/button.js +57 -0
  8. package/dist/renderer/blocks/divider.d.ts +18 -0
  9. package/dist/renderer/blocks/divider.d.ts.map +1 -0
  10. package/dist/renderer/blocks/divider.js +42 -0
  11. package/dist/renderer/blocks/highlight.d.ts +18 -0
  12. package/dist/renderer/blocks/highlight.d.ts.map +1 -0
  13. package/dist/renderer/blocks/highlight.js +49 -0
  14. package/dist/renderer/blocks/image.d.ts +18 -0
  15. package/dist/renderer/blocks/image.d.ts.map +1 -0
  16. package/dist/renderer/blocks/image.js +59 -0
  17. package/dist/renderer/blocks/paragraph.d.ts +18 -0
  18. package/dist/renderer/blocks/paragraph.d.ts.map +1 -0
  19. package/dist/renderer/blocks/paragraph.js +41 -0
  20. package/dist/renderer/blocks/title.d.ts +18 -0
  21. package/dist/renderer/blocks/title.d.ts.map +1 -0
  22. package/dist/renderer/blocks/title.js +49 -0
  23. package/dist/renderer/parseInlineFormatting.d.ts +14 -0
  24. package/dist/renderer/parseInlineFormatting.d.ts.map +1 -0
  25. package/dist/renderer/parseInlineFormatting.js +178 -0
  26. package/dist/renderer/renderBlock.d.ts +21 -0
  27. package/dist/renderer/renderBlock.d.ts.map +1 -0
  28. package/dist/renderer/renderBlock.js +44 -0
  29. package/dist/renderer/renderEmail.d.ts +26 -0
  30. package/dist/renderer/renderEmail.d.ts.map +1 -0
  31. package/dist/renderer/renderEmail.js +275 -0
  32. package/dist/sanitizer.d.ts +147 -0
  33. package/dist/sanitizer.d.ts.map +1 -0
  34. package/dist/sanitizer.js +533 -0
  35. package/dist/template-config.d.ts +38 -0
  36. package/dist/template-config.d.ts.map +1 -0
  37. package/dist/template-config.js +196 -0
  38. package/dist/test-formatting.d.ts +6 -0
  39. package/dist/test-formatting.d.ts.map +1 -0
  40. package/dist/test-formatting.js +132 -0
  41. package/dist/types.d.ts +243 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +5 -0
  44. package/dist/validator.d.ts +86 -0
  45. package/dist/validator.d.ts.map +1 -0
  46. package/dist/validator.js +435 -0
  47. package/package.json +17 -0
@@ -0,0 +1,49 @@
1
+ /**
2
+ * TITLE BLOCK RENDERER
3
+ *
4
+ * Responsibility: Render a single title block to HTML
5
+ * - Receives: TitleBlock
6
+ * - Returns: HTML string for this block only
7
+ * - Owns: All styling for title blocks
8
+ * - Does NOT: Know about other blocks, templates, or document structure
9
+ */
10
+ import { parseInlineFormatting } from '../parseInlineFormatting.js';
11
+ /**
12
+ * Render a title block to HTML
13
+ *
14
+ * @param block - The title block to render
15
+ * @returns HTML string for this block
16
+ */
17
+ export function renderTitleBlock(block) {
18
+ // Determine heading tag based on level
19
+ const tag = block.level || 'h2';
20
+ // Font size based on heading level
21
+ const fontSizeMap = {
22
+ h1: '28px',
23
+ h2: '24px',
24
+ h3: '20px',
25
+ };
26
+ const fontSize = fontSizeMap[tag] || '24px';
27
+ // Default colors if not specified
28
+ const color = block.color || '#1a1a1a';
29
+ const paddingBottom = block.paddingBottom || 20;
30
+ // Inline styles for title
31
+ const styles = [
32
+ `font-size: ${fontSize}`,
33
+ 'font-weight: 700',
34
+ `color: ${color}`,
35
+ `padding-bottom: ${paddingBottom}px`,
36
+ 'margin-top: 0',
37
+ 'line-height: 1.3',
38
+ ].join(';');
39
+ // HTML table wrapper (email compatibility)
40
+ return `
41
+ <table width="100%" cellpadding="0" cellspacing="0" border="0">
42
+ <tr>
43
+ <td style="${styles}">
44
+ ${parseInlineFormatting(block.content)}
45
+ </td>
46
+ </tr>
47
+ </table>
48
+ `.trim();
49
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Parse inline formatting tokens in text
3
+ *
4
+ * Converts tokens like:
5
+ * - {{bold:#HEX_COLOR}}text{{/bold}} to safe inline <span> with color and font-weight
6
+ * - {{link:URL}}text{{/link}} to safe inline <a> with href and inline styles
7
+ * - {{link:URL|bold|color:#HEX}}text{{/link}} to <a> with combined formatting
8
+ * - {{style:weight|color:#HEX}}text{{/style}} to <span> with unified styling
9
+ *
10
+ * @param text - The text string containing formatting tokens
11
+ * @returns HTML string with formatted spans and links
12
+ */
13
+ export declare function parseInlineFormatting(text: string): string;
14
+ //# sourceMappingURL=parseInlineFormatting.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parseInlineFormatting.d.ts","sourceRoot":"","sources":["../../src/renderer/parseInlineFormatting.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAoFH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAqG1D"}
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Parse inline formatting tokens in text
3
+ *
4
+ * Converts tokens like:
5
+ * - {{bold:#HEX_COLOR}}text{{/bold}} to safe inline <span> with color and font-weight
6
+ * - {{link:URL}}text{{/link}} to safe inline <a> with href and inline styles
7
+ * - {{link:URL|bold|color:#HEX}}text{{/link}} to <a> with combined formatting
8
+ * - {{style:weight|color:#HEX}}text{{/style}} to <span> with unified styling
9
+ *
10
+ * @param text - The text string containing formatting tokens
11
+ * @returns HTML string with formatted spans and links
12
+ */
13
+ /**
14
+ * Validate if URL is safe to use in href
15
+ */
16
+ function isValidUrl(url) {
17
+ if (!url || typeof url !== 'string')
18
+ return false;
19
+ // Whitelist: allow https://, http://, mailto:, https://wa.me/
20
+ if (url.startsWith('https://'))
21
+ return true;
22
+ if (url.startsWith('http://'))
23
+ return true;
24
+ if (url.startsWith('mailto:'))
25
+ return true;
26
+ if (url.startsWith('https://wa.me/'))
27
+ return true;
28
+ // Blacklist: reject javascript:, data:, vbscript:, file:
29
+ if (url.startsWith('javascript:'))
30
+ return false;
31
+ if (url.startsWith('data:'))
32
+ return false;
33
+ if (url.startsWith('vbscript:'))
34
+ return false;
35
+ if (url.startsWith('file:'))
36
+ return false;
37
+ return false;
38
+ }
39
+ /**
40
+ * Parse link modifiers from token
41
+ * Format: {{link:URL|bold|color:#HEX}}
42
+ * Returns: { url, hasBold, color }
43
+ */
44
+ function parseLinkModifiers(spec) {
45
+ const parts = spec.split('|').map((p) => p.trim());
46
+ const url = parts[0];
47
+ let hasBold = false;
48
+ let color = null;
49
+ for (let i = 1; i < parts.length; i++) {
50
+ const part = parts[i];
51
+ if (part === 'bold') {
52
+ hasBold = true;
53
+ }
54
+ else if (part.startsWith('color:')) {
55
+ const colorValue = part.substring(6); // Remove "color:" prefix
56
+ // Validate hex color
57
+ if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(colorValue)) {
58
+ color = colorValue;
59
+ }
60
+ }
61
+ }
62
+ return { url, hasBold, color };
63
+ }
64
+ /**
65
+ * Parse style modifiers from token
66
+ * Format: {{style:weight|color:#HEX}}
67
+ * Weight ∈ normal, semibold, bold (optional)
68
+ * Color format: color:#HEX (optional)
69
+ * Returns: { weight, color, isValid }
70
+ */
71
+ function parseStyleModifiers(spec) {
72
+ const parts = spec.split('|').map((p) => p.trim());
73
+ let weight = null;
74
+ let color = null;
75
+ let hasOption = false;
76
+ for (let i = 0; i < parts.length; i++) {
77
+ const part = parts[i];
78
+ // Check for weight
79
+ if (part === 'normal' || part === 'semibold' || part === 'bold') {
80
+ weight = part;
81
+ hasOption = true;
82
+ }
83
+ else if (part.startsWith('color:')) {
84
+ const colorValue = part.substring(6); // Remove "color:" prefix
85
+ // Validate hex color
86
+ if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(colorValue)) {
87
+ color = colorValue;
88
+ hasOption = true;
89
+ }
90
+ }
91
+ }
92
+ // At least one option must exist
93
+ return { weight, color, isValid: hasOption };
94
+ }
95
+ export function parseInlineFormatting(text) {
96
+ let result = text;
97
+ // Step 1: Parse bold tokens first
98
+ const boldRegex = /\{\{bold:(#[0-9A-Fa-f]{3}|#[0-9A-Fa-f]{6})\}\}(.*?)\{\{\/bold\}\}/g;
99
+ let boldMatch;
100
+ while ((boldMatch = boldRegex.exec(text)) !== null) {
101
+ const color = boldMatch[1]; // e.g., #008867
102
+ const contentText = boldMatch[2]; // The text to format
103
+ const fullToken = boldMatch[0]; // The entire {{bold:#...}}...{{/bold}}
104
+ // Create the replacement span with inline styles
105
+ const replacement = `<span style="color:${color};font-weight:700;">${contentText}</span>`;
106
+ // Replace only this specific token occurrence
107
+ result = result.replace(fullToken, replacement);
108
+ }
109
+ // Step 2: Parse unified style tokens
110
+ // Regex to match {{style:modifiers}}...{{/style}}
111
+ const styleRegex = /\{\{style:([^}]+)\}\}(.*?)\{\{\/style\}\}/g;
112
+ let styleMatch;
113
+ while ((styleMatch = styleRegex.exec(result)) !== null) {
114
+ const spec = styleMatch[1]; // e.g., "bold|color:#008867"
115
+ const contentText = styleMatch[2]; // The text to format
116
+ const fullToken = styleMatch[0]; // The entire {{style:...}}...{{/style}}
117
+ // Parse style modifiers
118
+ const { weight, color, isValid } = parseStyleModifiers(spec);
119
+ // Skip invalid tokens (at least one option must exist)
120
+ if (!isValid) {
121
+ result = result.replace(fullToken, contentText);
122
+ continue;
123
+ }
124
+ // Build inline styles for the span
125
+ const styles = [];
126
+ // Add color if specified
127
+ if (color) {
128
+ styles.push(`color:${color}`);
129
+ }
130
+ // Add font-weight based on weight value
131
+ if (weight === 'bold') {
132
+ styles.push('font-weight:700');
133
+ }
134
+ else if (weight === 'semibold') {
135
+ styles.push('font-weight:600');
136
+ }
137
+ else if (weight === 'normal') {
138
+ styles.push('font-weight:400');
139
+ }
140
+ // Create the replacement span with inline styles
141
+ const replacement = `<span style="${styles.join(';')};">${contentText}</span>`;
142
+ // Replace only this specific token occurrence
143
+ result = result.replace(fullToken, replacement);
144
+ }
145
+ // Step 3: Parse combined link tokens with optional modifiers
146
+ // Regex to match {{link:URL|modifiers}}...{{/link}}
147
+ const combinedLinkRegex = /\{\{link:([^}]+)\}\}(.*?)\{\{\/link\}\}/g;
148
+ let combinedLinkMatch;
149
+ while ((combinedLinkMatch = combinedLinkRegex.exec(result)) !== null) {
150
+ const spec = combinedLinkMatch[1]; // e.g., "URL|bold|color:#008867"
151
+ const linkText = combinedLinkMatch[2]; // The text to display
152
+ const fullToken = combinedLinkMatch[0]; // The entire {{link:...}}...{{/link}}
153
+ // Parse URL and modifiers
154
+ const { url, hasBold, color } = parseLinkModifiers(spec);
155
+ // Validate URL before rendering
156
+ if (!isValidUrl(url)) {
157
+ // Invalid URL: render plain text without link
158
+ result = result.replace(fullToken, linkText);
159
+ continue;
160
+ }
161
+ // Build inline styles for the anchor
162
+ const styles = [];
163
+ const linkColor = color || '#008867'; // Default color
164
+ styles.push(`color:${linkColor}`);
165
+ styles.push('text-decoration:none');
166
+ if (hasBold) {
167
+ styles.push('font-weight:700');
168
+ }
169
+ else {
170
+ styles.push('font-weight:600');
171
+ }
172
+ // Create the replacement anchor with inline styles
173
+ const replacement = `<a href="${url}" style="${styles.join(';')};" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
174
+ // Replace only this specific token occurrence
175
+ result = result.replace(fullToken, replacement);
176
+ }
177
+ return result;
178
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * BLOCK DISPATCHER
3
+ *
4
+ * Responsibility: Route blocks to correct renderer
5
+ * - Receives: Block (union type of all block types)
6
+ * - Returns: HTML string for that block
7
+ * - Does NOT: Generate HTML itself, contain block logic, know about document structure
8
+ */
9
+ import type { Block } from '../types.js';
10
+ /**
11
+ * Render any block to HTML
12
+ *
13
+ * Routes block to correct renderer based on block type.
14
+ * Each block type handler owns its complete HTML generation.
15
+ *
16
+ * @param block - The block to render (any block type)
17
+ * @returns HTML string for this block
18
+ * @throws Error if block type is unknown
19
+ */
20
+ export declare function renderBlock(block: Block): string;
21
+ //# sourceMappingURL=renderBlock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"renderBlock.d.ts","sourceRoot":"","sources":["../../src/renderer/renderBlock.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAQzC;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAyBhD"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * BLOCK DISPATCHER
3
+ *
4
+ * Responsibility: Route blocks to correct renderer
5
+ * - Receives: Block (union type of all block types)
6
+ * - Returns: HTML string for that block
7
+ * - Does NOT: Generate HTML itself, contain block logic, know about document structure
8
+ */
9
+ import { renderTitleBlock } from './blocks/title.js';
10
+ import { renderParagraphBlock } from './blocks/paragraph.js';
11
+ import { renderImageBlock } from './blocks/image.js';
12
+ import { renderButtonBlock } from './blocks/button.js';
13
+ import { renderDividerBlock } from './blocks/divider.js';
14
+ import { renderHighlightBlock } from './blocks/highlight.js';
15
+ /**
16
+ * Render any block to HTML
17
+ *
18
+ * Routes block to correct renderer based on block type.
19
+ * Each block type handler owns its complete HTML generation.
20
+ *
21
+ * @param block - The block to render (any block type)
22
+ * @returns HTML string for this block
23
+ * @throws Error if block type is unknown
24
+ */
25
+ export function renderBlock(block) {
26
+ switch (block.type) {
27
+ case 'title':
28
+ return renderTitleBlock(block);
29
+ case 'paragraph':
30
+ return renderParagraphBlock(block);
31
+ case 'image':
32
+ return renderImageBlock(block);
33
+ case 'button':
34
+ return renderButtonBlock(block);
35
+ case 'divider':
36
+ return renderDividerBlock(block);
37
+ case 'highlight-box':
38
+ return renderHighlightBlock(block);
39
+ default:
40
+ // TypeScript exhaustiveness check - this should never happen in production
41
+ // but provides helpful error message if a new block type is added but not handled
42
+ throw new Error(`Unknown block type: ${block.type}`);
43
+ }
44
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * EMAIL RENDERER
3
+ *
4
+ * Responsibility: Assemble complete HTML email document
5
+ * - Receives: EmailDocument
6
+ * - Returns: Complete HTML email string
7
+ * - Does NOT: Generate block HTML (delegates to renderBlock), know about specific block types
8
+ */
9
+ import type { EmailDocument } from "../types.js";
10
+ /**
11
+ * Render complete HTML email from EmailDocument
12
+ *
13
+ * Generates a fully-formed HTML email with:
14
+ * - DOCTYPE and basic HTML structure
15
+ * - Email-safe inline styles in head
16
+ * - Header section (logo/company info)
17
+ * - Body blocks (rendered by block renderer)
18
+ * - Help section (contact information)
19
+ * - Compliance section (legal notice)
20
+ * - Footer section (company info, social links)
21
+ *
22
+ * @param email - The EmailDocument to render
23
+ * @returns Complete HTML email string
24
+ */
25
+ export declare function renderEmail(email: EmailDocument): string;
26
+ //# sourceMappingURL=renderEmail.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"renderEmail.d.ts","sourceRoot":"","sources":["../../src/renderer/renderEmail.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAGjD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAmRxD"}
@@ -0,0 +1,275 @@
1
+ /**
2
+ * EMAIL RENDERER
3
+ *
4
+ * Responsibility: Assemble complete HTML email document
5
+ * - Receives: EmailDocument
6
+ * - Returns: Complete HTML email string
7
+ * - Does NOT: Generate block HTML (delegates to renderBlock), know about specific block types
8
+ */
9
+ import { renderBlock } from "./renderBlock.js";
10
+ /**
11
+ * Render complete HTML email from EmailDocument
12
+ *
13
+ * Generates a fully-formed HTML email with:
14
+ * - DOCTYPE and basic HTML structure
15
+ * - Email-safe inline styles in head
16
+ * - Header section (logo/company info)
17
+ * - Body blocks (rendered by block renderer)
18
+ * - Help section (contact information)
19
+ * - Compliance section (legal notice)
20
+ * - Footer section (company info, social links)
21
+ *
22
+ * @param email - The EmailDocument to render
23
+ * @returns Complete HTML email string
24
+ */
25
+ export function renderEmail(email) {
26
+ // Render all body blocks
27
+ const bodyBlocksHtml = email.body.blocks
28
+ .map((block) => renderBlock(block))
29
+ .join("\n");
30
+ // Build help section HTML
31
+ let helpSectionHtml = "";
32
+ if (email.helpSection) {
33
+ const contactItemsHtml = (email.helpSection.contactItems || [])
34
+ .map((item) => {
35
+ // Map icon URL based on contact type
36
+ let iconUrl = "";
37
+ if (item.type === "email") {
38
+ iconUrl = "https://nobi.id/icons/icon-mail.png";
39
+ }
40
+ else if (item.type === "phone" || item.type === "whatsapp") {
41
+ iconUrl = "https://nobi.id/icons/icon-phone.png";
42
+ }
43
+ return `
44
+ <table cellpadding="0" cellspacing="0" role="presentation"
45
+ style="margin-bottom: 8px;">
46
+ <tr>
47
+ <td style="padding-right: 8px;" valign="middle">
48
+ <img src="${iconUrl}" height="20"
49
+ width="20" style="display: block;" alt="${item.label}" />
50
+ </td>
51
+ <td valign="middle">
52
+ <a href="${item.href}"
53
+ style="color: #008867; font-size: 14px; text-decoration: none;">
54
+ ${item.value}
55
+ </a>
56
+ </td>
57
+ </tr>
58
+ </table>`;
59
+ })
60
+ .join("\n");
61
+ helpSectionHtml = `
62
+ <tr>
63
+ <td style="padding: 40px; background-color: #f6f6f6;">
64
+ <table width="100%" cellpadding="0" cellspacing="0" border="0">
65
+ <tr>
66
+ <!-- Kolom Teks -->
67
+ <td class="stack-column" valign="top" style="padding-right: 20px; width: 60%;">
68
+ <h3
69
+ style="font-size: 20px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px;">
70
+ ${email.helpSection.title ||
71
+ "Butuh Bantuan untuk Mulai?"}
72
+ </h3>
73
+ <p
74
+ style="line-height: 1.5; margin-top: 12px; color: #444; margin-bottom: 16px;">
75
+ ${email.helpSection.description ||
76
+ ""}
77
+ </p>
78
+ ${contactItemsHtml}
79
+ </td>
80
+
81
+ <!-- Kolom Gambar -->
82
+ <td class="stack-column" style="width: 40%; text-align: right;" valign="middle">
83
+ <img src="${email.helpSection.imageUrl || ""}" alt="${email.helpSection.title || "Help"}"
84
+ width="150" style="border-radius: 5px; height: 150px; width: auto;" />
85
+ </td>
86
+ </tr>
87
+ </table>
88
+ </td>
89
+ </tr>`;
90
+ }
91
+ // Build footer social links HTML (table-based icons)
92
+ let socialLinksHtml = "";
93
+ if (email.footer.socialLinks && email.footer.socialLinks.length > 0) {
94
+ const socialIconsHtml = email.footer.socialLinks
95
+ .map((link, index) => {
96
+ // Map icon URL based on platform
97
+ let iconUrl = "";
98
+ if (link.platform === "instagram") {
99
+ iconUrl =
100
+ "https://cdn.tools.unlayer.com/social/icons/circle-black/instagram.png";
101
+ }
102
+ else if (link.platform === "twitter") {
103
+ iconUrl =
104
+ "https://cdn.tools.unlayer.com/social/icons/circle-black/twitter.png";
105
+ }
106
+ else if (link.platform === "linkedin") {
107
+ iconUrl =
108
+ "https://cdn.tools.unlayer.com/social/icons/circle-black/linkedin.png";
109
+ }
110
+ else if (link.platform === "facebook") {
111
+ iconUrl =
112
+ "https://cdn.tools.unlayer.com/social/icons/circle-black/facebook.png";
113
+ }
114
+ else if (link.platform === "email") {
115
+ iconUrl =
116
+ "https://cdn.tools.unlayer.com/social/icons/circle-black/email.png";
117
+ }
118
+ else if (link.platform === "whatsapp") {
119
+ iconUrl =
120
+ "https://cdn.tools.unlayer.com/social/icons/circle-black/whatsapp.png";
121
+ }
122
+ // Last item has margin-right: 0px, others have 5px
123
+ const isLast = index === email.footer.socialLinks.length - 1;
124
+ const marginRight = isLast ? "0px" : "5px";
125
+ return `
126
+ <table role="presentation" border="0" cellspacing="0"
127
+ cellpadding="0" width="32" height="32"
128
+ style="width:32px!important;height:32px!important;display:inline-block;border-collapse:collapse;table-layout:fixed;border-spacing:0;vertical-align:top;margin-right:${marginRight}">
129
+ <tbody>
130
+ <tr style="vertical-align:top">
131
+ <td valign="middle"
132
+ style="word-break:break-word;border-collapse:collapse!important;vertical-align:top">
133
+ <a href="${link.url}" title="${link.platform}"
134
+ target="_blank">
135
+ <img src="${iconUrl}"
136
+ alt="${link.platform}" title="${link.platform}" width="32"
137
+ style="outline:none;text-decoration:none;clear:both;display:block!important;border:none;height:auto;float:none;max-width:32px!important"
138
+ class="CToWUd" data-bit="iit">
139
+ </a>
140
+ </td>
141
+ </tr>
142
+ </tbody>
143
+ </table>`;
144
+ })
145
+ .join("\n");
146
+ socialLinksHtml = socialIconsHtml;
147
+ }
148
+ // Build compliance section HTML
149
+ const complianceText = email.complianceSection.text || "";
150
+ const sandboxNumber = email.complianceSection.sandboxNumber
151
+ ? `<strong>${email.complianceSection.sandboxNumber}</strong>`
152
+ : "";
153
+ // HTML email structure with email-safe styling
154
+ const html = `<!DOCTYPE html>
155
+ <html lang="en">
156
+ <head>
157
+ <meta charset="UTF-8">
158
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
159
+ <title>${email.templateType}</title>
160
+ <style type="text/css">
161
+ /* Reset styles for better email client compatibility */
162
+ body {
163
+ margin: 0;
164
+ padding: 0;
165
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
166
+ line-height: 1.6;
167
+ color: #1a1a1a;
168
+ }
169
+ table {
170
+ border-collapse: collapse;
171
+ }
172
+ img {
173
+ display: block;
174
+ outline: none;
175
+ border: none;
176
+ text-decoration: none;
177
+ }
178
+ a {
179
+ color: inherit;
180
+ text-decoration: none;
181
+ }
182
+ </style>
183
+ </head>
184
+ <body style="margin: 0; padding: 0; background-color: #f8fafc;">
185
+ <!-- Outer container -->
186
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f8fafc">
187
+ <!-- HEADER SECTION -->
188
+ <tr>
189
+ <td align="center">
190
+ <span style="display:inline-block; margin:20px 0;">
191
+ ${email.header.logoUrl
192
+ ? `<img src="${email.header.logoUrl}" alt="Logo" style="height: ${email.header.logoHeight || 32}px; max-width: 100%;" />`
193
+ : '<h1 style="margin: 0; font-size: 24px; font-weight: bold; color: #1a1a1a;">Email</h1>'}
194
+ </span>
195
+ </td>
196
+ </tr>
197
+
198
+ <tr>
199
+ <td align="center">
200
+ <!-- Main email container (max 600px width) -->
201
+ <table width="600" cellpadding="0" cellspacing="0" border="0" bgcolor="#ffffff" style="max-width: 100%;">
202
+ <!-- BODY BLOCKS SECTION -->
203
+ <tr>
204
+ <td style="padding: 20px;">
205
+ ${bodyBlocksHtml}
206
+ </td>
207
+ </tr>
208
+
209
+ <!-- HELP SECTION (if provided) -->
210
+ ${helpSectionHtml}
211
+
212
+ <!-- COMPLIANCE SECTION -->
213
+ <table width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #e4f4f0;">
214
+ <tr>
215
+ <td align="center" style="padding: 0;">
216
+ <table width="100%" cellpadding="12" cellspacing="0"
217
+ style="max-width: 600px; background-color: #e4f4f0; border-radius: 0;">
218
+ <tr>
219
+ <td align="center"
220
+ style="font-family: Arial, sans-serif; color: #006950; font-size: 15px; line-height: 1.5;">
221
+ <strong>PT Dana Kripto Indonesia</strong><br />
222
+ sebagai peserta sandbox OJK dengan nomor surat
223
+ <strong>${sandboxNumber}</strong>
224
+ </td>
225
+ </tr>
226
+ </table>
227
+ </td>
228
+ </tr>
229
+ </table>
230
+
231
+ <!-- FOOTER SECTION -->
232
+ <tr>
233
+ <td style="
234
+ padding: 30px 40px 20px 40px;
235
+ background-color: #006950;
236
+ color: white;
237
+ font-size: 14px;
238
+ " class="responsive-padding-x">
239
+ ${email.footer.logoUrl
240
+ ? `<img src="${email.footer.logoUrl}" height="20"
241
+ style="margin-bottom: 8px" />`
242
+ : ""}
243
+ <p style="margin: 4px 0; font-weight: 700;">${email.footer.companyName || "PT. Company Indonesia"}</p>
244
+ ${email.footer.address
245
+ ? `<p style="margin: 4px 0">${email.footer.address}</p>`
246
+ : ""}
247
+ <table style="font-family:'Open Sans',sans-serif" role="presentation" cellpadding="0"
248
+ cellspacing="0" width="100%" border="0">
249
+ <tbody>
250
+ <tr>
251
+ <td style="word-break:break-word;padding-top:10px;font-family:'Open Sans',sans-serif"
252
+ align="left">
253
+
254
+ <div align="left" style="direction:ltr">
255
+ <div style="display:table;max-width:${email.footer.socialLinks &&
256
+ email.footer.socialLinks.length > 0
257
+ ? email.footer.socialLinks.length * 37 + "px"
258
+ : "0"}">
259
+ ${socialLinksHtml}
260
+ </div>
261
+ </div>
262
+ </td>
263
+ </tr>
264
+ </tbody>
265
+ </table>
266
+ </td>
267
+ </tr>
268
+ </table>
269
+ </td>
270
+ </tr>
271
+ </table>
272
+ </body>
273
+ </html>`;
274
+ return html.trim();
275
+ }