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,147 @@
1
+ /**
2
+ * TEXT SANITIZATION STRATEGY
3
+ * Ensures input safety and email client compatibility
4
+ */
5
+ import type { AllowedInlineTag, TextSanitizationConfig, BlockType } from './types.js';
6
+ /**
7
+ * Global sanitization rules
8
+ * Applied to all text content
9
+ */
10
+ export declare const GLOBAL_SANITIZATION_CONFIG: TextSanitizationConfig;
11
+ /**
12
+ * Block-type specific sanitization rules
13
+ * Some blocks allow more flexibility than others
14
+ */
15
+ export declare const BLOCK_SANITIZATION_CONFIG: Record<BlockType, TextSanitizationConfig>;
16
+ /**
17
+ * Escape HTML special characters
18
+ * Prevents XSS by converting dangerous chars to entities
19
+ *
20
+ * @param text - Raw text to escape
21
+ * @returns Escaped text safe for HTML
22
+ */
23
+ export declare function escapeHtml(text: string): string;
24
+ /**
25
+ * Validate URL protocol
26
+ * Ensures only safe protocols are used
27
+ *
28
+ * @param url - URL to validate
29
+ * @returns true if URL uses safe protocol
30
+ */
31
+ export declare function isValidUrlProtocol(url: string): boolean;
32
+ /**
33
+ * Remove all HTML tags from text
34
+ * Used for blocks that don't allow any HTML
35
+ *
36
+ * @param text - HTML text
37
+ * @returns Plain text with all tags removed
38
+ */
39
+ export declare function stripAllHtml(text: string): string;
40
+ /**
41
+ * Parse and validate HTML attributes
42
+ * Returns only allowed attributes with validated values
43
+ *
44
+ * @param tag - HTML tag name
45
+ * @param attributes - Raw attributes from HTML
46
+ * @param allowedAttrs - List of allowed attribute names
47
+ * @returns Clean attribute object
48
+ */
49
+ export declare function sanitizeAttributes(tag: string, attributes: Record<string, string>, allowedAttrs: string[]): Record<string, string>;
50
+ /**
51
+ * Sanitize HTML content
52
+ * Removes dangerous tags/attributes while preserving allowed inline tags
53
+ *
54
+ * PSEUDOCODE:
55
+ * 1. Parse HTML into tokens (tags and text)
56
+ * 2. For each token:
57
+ * - If text: escape special characters
58
+ * - If tag:
59
+ * - If dangerous tag: remove
60
+ * - If not in allowed list: remove
61
+ * - If allowed: sanitize attributes and keep
62
+ * 3. Reconstruct HTML from clean tokens
63
+ * 4. Validate against allowed tag list
64
+ *
65
+ * @param html - Raw HTML input
66
+ * @param allowedTags - List of allowed tag names
67
+ * @param stripStyles - If true, remove all style attributes
68
+ * @returns Sanitized HTML
69
+ */
70
+ export declare function sanitizeHtml(html: string, allowedTags: AllowedInlineTag[]): string;
71
+ /**
72
+ * Validate and sanitize text content per block type
73
+ * Main entry point for text sanitization
74
+ *
75
+ * PSEUDOCODE:
76
+ * 1. Get sanitization config for block type
77
+ * 2. If strict mode (no HTML allowed):
78
+ * - Strip all HTML
79
+ * - Escape characters
80
+ * 3. If HTML allowed:
81
+ * - Sanitize HTML keeping only allowed tags
82
+ * - Validate and clean attributes
83
+ * - Escape dangerous characters
84
+ * 4. Return clean text
85
+ *
86
+ * @param text - Raw input text
87
+ * @param blockType - Type of block containing the text
88
+ * @returns Sanitized text safe for email
89
+ */
90
+ export declare function sanitizeTextContent(text: string, blockType: BlockType): string;
91
+ /**
92
+ * Validate URL for href/src attributes
93
+ * Ensures protocols are safe and format is valid
94
+ *
95
+ * @param url - URL to validate
96
+ * @param requireHttps - If true, reject http://
97
+ * @returns true if valid
98
+ */
99
+ export declare function isValidUrl(url: string, requireHttps?: boolean): boolean;
100
+ /**
101
+ * Sanitize button label
102
+ * Buttons don't support HTML - only plain text
103
+ *
104
+ * @param label - Button label text
105
+ * @returns Sanitized label
106
+ */
107
+ export declare function sanitizeButtonLabel(label: string): string;
108
+ /**
109
+ * Sanitize image alt text
110
+ * Should be plain text only
111
+ *
112
+ * @param alt - Alt text
113
+ * @returns Sanitized alt text
114
+ */
115
+ export declare function sanitizeImageAlt(alt: string): string;
116
+ /**
117
+ * Batch sanitization for entire block object
118
+ * Applies type-specific sanitization to all text fields
119
+ *
120
+ * PSEUDOCODE:
121
+ * 1. Get block type
122
+ * 2. For each text field in block:
123
+ * - Apply appropriate sanitization function
124
+ * - Validate URLs if applicable
125
+ * - Check color formats
126
+ * 3. Return sanitized block copy
127
+ *
128
+ * @param block - Block object to sanitize
129
+ * @returns New block with sanitized content
130
+ */
131
+ export declare function sanitizeBlock<T extends {
132
+ type: BlockType;
133
+ id: string;
134
+ }>(block: T): T;
135
+ export interface SanitizationResult {
136
+ success: boolean;
137
+ original: string;
138
+ sanitized: string;
139
+ removedCount: number;
140
+ warnings: string[];
141
+ }
142
+ /**
143
+ * Get detailed sanitization report
144
+ * Useful for debugging and user feedback
145
+ */
146
+ export declare function getSanitizationReport(original: string, blockType: BlockType): SanitizationResult;
147
+ //# sourceMappingURL=sanitizer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sanitizer.d.ts","sourceRoot":"","sources":["../src/sanitizer.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAMtF;;;GAGG;AACH,eAAO,MAAM,0BAA0B,EAAE,sBAqBxC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,yBAAyB,EAAE,MAAM,CAAC,SAAS,EAAE,sBAAsB,CAsG/E,CAAC;AA4GF;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAQvD;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAejD;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,YAAY,EAAE,MAAM,EAAE,GACrB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA+BxB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,gBAAgB,EAAE,GAAG,MAAM,CA4ClF;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,GAAG,MAAM,CAgB9E;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,YAAY,GAAE,OAAe,GAAG,OAAO,CAgB9E;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQzD;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAMpD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CAyCpF;AAMD,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,SAAS,GACnB,kBAAkB,CA6BpB"}
@@ -0,0 +1,533 @@
1
+ /**
2
+ * TEXT SANITIZATION STRATEGY
3
+ * Ensures input safety and email client compatibility
4
+ */
5
+ // ============================================================================
6
+ // SANITIZATION CONFIGURATION
7
+ // ============================================================================
8
+ /**
9
+ * Global sanitization rules
10
+ * Applied to all text content
11
+ */
12
+ export const GLOBAL_SANITIZATION_CONFIG = {
13
+ // Strip all HTML except explicitly allowed tags
14
+ stripAllHTMLExcept: ['strong', 'b', 'em', 'i', 'u', 'a', 'br'],
15
+ // Always strip inline styles
16
+ stripAllStyles: true,
17
+ // Escape unsafe characters to prevent XSS
18
+ escapeUnsafeCharacters: true,
19
+ // Per-tag restrictions
20
+ tagRestrictions: {
21
+ a: {
22
+ allowedAttributes: ['href'],
23
+ requireHttpProtocol: true,
24
+ },
25
+ img: {
26
+ allowedAttributes: ['src', 'alt', 'width', 'height'],
27
+ requireHttpProtocol: true,
28
+ },
29
+ },
30
+ };
31
+ /**
32
+ * Block-type specific sanitization rules
33
+ * Some blocks allow more flexibility than others
34
+ */
35
+ export const BLOCK_SANITIZATION_CONFIG = {
36
+ // Titles: plain text + basic emphasis
37
+ title: {
38
+ stripAllHTMLExcept: ['strong', 'b', 'em', 'i'],
39
+ stripAllStyles: true,
40
+ escapeUnsafeCharacters: true,
41
+ tagRestrictions: {
42
+ a: {
43
+ allowedAttributes: ['href'],
44
+ requireHttpProtocol: true,
45
+ },
46
+ img: {
47
+ allowedAttributes: ['src', 'alt', 'width', 'height'],
48
+ requireHttpProtocol: true,
49
+ },
50
+ },
51
+ },
52
+ // Paragraphs: most flexible - allow links and emphasis
53
+ paragraph: {
54
+ stripAllHTMLExcept: ['strong', 'b', 'em', 'i', 'u', 'a', 'br'],
55
+ stripAllStyles: true,
56
+ escapeUnsafeCharacters: true,
57
+ tagRestrictions: {
58
+ a: {
59
+ allowedAttributes: ['href'],
60
+ requireHttpProtocol: true,
61
+ },
62
+ img: {
63
+ allowedAttributes: ['src', 'alt', 'width', 'height'],
64
+ requireHttpProtocol: true,
65
+ },
66
+ },
67
+ },
68
+ // Images: no text content to sanitize
69
+ image: {
70
+ stripAllHTMLExcept: [],
71
+ stripAllStyles: true,
72
+ escapeUnsafeCharacters: true,
73
+ tagRestrictions: {
74
+ a: {
75
+ allowedAttributes: ['href'],
76
+ requireHttpProtocol: true,
77
+ },
78
+ img: {
79
+ allowedAttributes: ['src', 'alt', 'width', 'height'],
80
+ requireHttpProtocol: true,
81
+ },
82
+ },
83
+ },
84
+ // Buttons: plain text only, no HTML allowed
85
+ button: {
86
+ stripAllHTMLExcept: [],
87
+ stripAllStyles: true,
88
+ escapeUnsafeCharacters: true,
89
+ tagRestrictions: {
90
+ a: {
91
+ allowedAttributes: ['href'],
92
+ requireHttpProtocol: true,
93
+ },
94
+ img: {
95
+ allowedAttributes: ['src', 'alt', 'width', 'height'],
96
+ requireHttpProtocol: true,
97
+ },
98
+ },
99
+ },
100
+ // Dividers: no text content
101
+ divider: {
102
+ stripAllHTMLExcept: [],
103
+ stripAllStyles: true,
104
+ escapeUnsafeCharacters: true,
105
+ tagRestrictions: {
106
+ a: {
107
+ allowedAttributes: ['href'],
108
+ requireHttpProtocol: true,
109
+ },
110
+ img: {
111
+ allowedAttributes: ['src', 'alt', 'width', 'height'],
112
+ requireHttpProtocol: true,
113
+ },
114
+ },
115
+ },
116
+ // Highlight boxes: allow emphasis and links
117
+ 'highlight-box': {
118
+ stripAllHTMLExcept: ['strong', 'b', 'em', 'i', 'u', 'a', 'br'],
119
+ stripAllStyles: true,
120
+ escapeUnsafeCharacters: true,
121
+ tagRestrictions: {
122
+ a: {
123
+ allowedAttributes: ['href'],
124
+ requireHttpProtocol: true,
125
+ },
126
+ img: {
127
+ allowedAttributes: ['src', 'alt', 'width', 'height'],
128
+ requireHttpProtocol: true,
129
+ },
130
+ },
131
+ },
132
+ };
133
+ // ============================================================================
134
+ // PERSONALIZATION TOKEN HANDLING
135
+ // ============================================================================
136
+ const ALLOWED_PERSONALIZATION_TOKENS = new Set(['firstName', 'lastName', 'email']);
137
+ /**
138
+ * Extract allowed personalization tokens and replace with safe placeholders
139
+ */
140
+ function extractPersonalizationTokens(text) {
141
+ const tokens = new Map();
142
+ let index = 0;
143
+ const modified = text.replace(/\{\{([^}]+)\}\}/g, (fullMatch, tokenName) => {
144
+ const trimmed = tokenName.trim();
145
+ if (ALLOWED_PERSONALIZATION_TOKENS.has(trimmed)) {
146
+ const placeholder = `__PERSONALIZATION_TOKEN_${index}__`;
147
+ tokens.set(placeholder, fullMatch);
148
+ index++;
149
+ return placeholder;
150
+ }
151
+ return '';
152
+ });
153
+ return { text: modified, tokens };
154
+ }
155
+ /**
156
+ * Restore original personalization tokens after sanitization
157
+ */
158
+ function restorePersonalizationTokens(text, tokens) {
159
+ let result = text;
160
+ tokens.forEach((original, placeholder) => {
161
+ result = result.split(placeholder).join(original);
162
+ });
163
+ return result;
164
+ }
165
+ // ============================================================================
166
+ // SANITIZATION FUNCTIONS
167
+ // ============================================================================
168
+ /**
169
+ * List of potentially dangerous HTML tags that must be stripped
170
+ */
171
+ const DANGEROUS_TAGS = [
172
+ 'script',
173
+ 'iframe',
174
+ 'object',
175
+ 'embed',
176
+ 'form',
177
+ 'input',
178
+ 'button',
179
+ 'textarea',
180
+ 'style',
181
+ 'link',
182
+ 'meta',
183
+ 'base',
184
+ ];
185
+ /**
186
+ * List of dangerous attributes that carry scripts/code
187
+ */
188
+ const DANGEROUS_ATTRIBUTES = [
189
+ 'onload',
190
+ 'onerror',
191
+ 'onclick',
192
+ 'onmouseover',
193
+ 'onmouseout',
194
+ 'onmousemove',
195
+ 'onmouseenter',
196
+ 'onmouseleave',
197
+ 'onchange',
198
+ 'onfocus',
199
+ 'onblur',
200
+ 'onsubmit',
201
+ 'onkeydown',
202
+ 'onkeyup',
203
+ 'onkeypress',
204
+ 'ondblclick',
205
+ 'ondrag',
206
+ 'ondrop',
207
+ 'onwheel',
208
+ 'onscroll',
209
+ 'style',
210
+ 'class',
211
+ 'id',
212
+ ];
213
+ /**
214
+ * Characters that need HTML entity escaping to prevent XSS
215
+ */
216
+ const UNSAFE_CHARS = {
217
+ '&': '&amp;',
218
+ '<': '&lt;',
219
+ '>': '&gt;',
220
+ '"': '&quot;',
221
+ "'": '&#39;',
222
+ '/': '&#x2F;',
223
+ };
224
+ /**
225
+ * Escape HTML special characters
226
+ * Prevents XSS by converting dangerous chars to entities
227
+ *
228
+ * @param text - Raw text to escape
229
+ * @returns Escaped text safe for HTML
230
+ */
231
+ export function escapeHtml(text) {
232
+ return text.replace(/[&<>"'\/]/g, (char) => UNSAFE_CHARS[char] ?? char);
233
+ }
234
+ /**
235
+ * Validate URL protocol
236
+ * Ensures only safe protocols are used
237
+ *
238
+ * @param url - URL to validate
239
+ * @returns true if URL uses safe protocol
240
+ */
241
+ export function isValidUrlProtocol(url) {
242
+ if (!url)
243
+ return false;
244
+ // Allowed protocols
245
+ const allowedProtocols = ['http://', 'https://', 'mailto:'];
246
+ const lowerUrl = url.toLowerCase().trim();
247
+ return allowedProtocols.some((protocol) => lowerUrl.startsWith(protocol));
248
+ }
249
+ /**
250
+ * Remove all HTML tags from text
251
+ * Used for blocks that don't allow any HTML
252
+ *
253
+ * @param text - HTML text
254
+ * @returns Plain text with all tags removed
255
+ */
256
+ export function stripAllHtml(text) {
257
+ return text
258
+ .replace(/<[^>]*>/g, '') // Remove all tags
259
+ .replace(/&[a-zA-Z0-9]+;/g, (entity) => {
260
+ // Decode common entities
261
+ const entityMap = {
262
+ '&amp;': '&',
263
+ '&lt;': '<',
264
+ '&gt;': '>',
265
+ '&quot;': '"',
266
+ '&#39;': "'",
267
+ '&nbsp;': ' ',
268
+ };
269
+ return entityMap[entity] ?? entity;
270
+ });
271
+ }
272
+ /**
273
+ * Parse and validate HTML attributes
274
+ * Returns only allowed attributes with validated values
275
+ *
276
+ * @param tag - HTML tag name
277
+ * @param attributes - Raw attributes from HTML
278
+ * @param allowedAttrs - List of allowed attribute names
279
+ * @returns Clean attribute object
280
+ */
281
+ export function sanitizeAttributes(tag, attributes, allowedAttrs) {
282
+ const clean = {};
283
+ Object.entries(attributes).forEach(([attrName, attrValue]) => {
284
+ const lowerAttrName = attrName.toLowerCase();
285
+ // Reject dangerous attributes
286
+ if (DANGEROUS_ATTRIBUTES.includes(lowerAttrName)) {
287
+ return;
288
+ }
289
+ // Only allow explicitly allowed attributes
290
+ if (!allowedAttrs.includes(lowerAttrName)) {
291
+ return;
292
+ }
293
+ // Validate specific attributes
294
+ if (lowerAttrName === 'href' || lowerAttrName === 'src') {
295
+ if (!isValidUrlProtocol(attrValue)) {
296
+ return; // Skip invalid URLs
297
+ }
298
+ clean[lowerAttrName] = escapeHtml(attrValue);
299
+ }
300
+ else if (lowerAttrName === 'alt') {
301
+ clean[lowerAttrName] = escapeHtml(attrValue);
302
+ }
303
+ else {
304
+ // For other allowed attributes, escape and include
305
+ clean[lowerAttrName] = escapeHtml(attrValue);
306
+ }
307
+ });
308
+ return clean;
309
+ }
310
+ /**
311
+ * Sanitize HTML content
312
+ * Removes dangerous tags/attributes while preserving allowed inline tags
313
+ *
314
+ * PSEUDOCODE:
315
+ * 1. Parse HTML into tokens (tags and text)
316
+ * 2. For each token:
317
+ * - If text: escape special characters
318
+ * - If tag:
319
+ * - If dangerous tag: remove
320
+ * - If not in allowed list: remove
321
+ * - If allowed: sanitize attributes and keep
322
+ * 3. Reconstruct HTML from clean tokens
323
+ * 4. Validate against allowed tag list
324
+ *
325
+ * @param html - Raw HTML input
326
+ * @param allowedTags - List of allowed tag names
327
+ * @param stripStyles - If true, remove all style attributes
328
+ * @returns Sanitized HTML
329
+ */
330
+ export function sanitizeHtml(html, allowedTags) {
331
+ if (!html)
332
+ return '';
333
+ // Simple tokenizer: split by tags
334
+ const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g;
335
+ const allowedTagSet = new Set(allowedTags.map((t) => t.toLowerCase()));
336
+ const dangerousTagSet = new Set(DANGEROUS_TAGS.map((t) => t.toLowerCase()));
337
+ let result = '';
338
+ let lastIndex = 0;
339
+ const matches = html.matchAll(tagRegex);
340
+ for (const match of matches) {
341
+ // Add text before tag
342
+ const textBefore = html.substring(lastIndex, match.index);
343
+ result += escapeHtml(textBefore);
344
+ const fullTag = match[0];
345
+ const tagName = match[1].toLowerCase();
346
+ const isClosing = fullTag.startsWith('</');
347
+ // Skip dangerous tags
348
+ if (dangerousTagSet.has(tagName)) {
349
+ lastIndex = match.index + fullTag.length;
350
+ continue;
351
+ }
352
+ // Skip disallowed tags
353
+ if (!allowedTagSet.has(tagName)) {
354
+ lastIndex = match.index + fullTag.length;
355
+ continue;
356
+ }
357
+ // Keep allowed tag
358
+ result += fullTag;
359
+ lastIndex = match.index + fullTag.length;
360
+ }
361
+ // Add remaining text
362
+ const textAfter = html.substring(lastIndex);
363
+ result += escapeHtml(textAfter);
364
+ return result;
365
+ }
366
+ /**
367
+ * Validate and sanitize text content per block type
368
+ * Main entry point for text sanitization
369
+ *
370
+ * PSEUDOCODE:
371
+ * 1. Get sanitization config for block type
372
+ * 2. If strict mode (no HTML allowed):
373
+ * - Strip all HTML
374
+ * - Escape characters
375
+ * 3. If HTML allowed:
376
+ * - Sanitize HTML keeping only allowed tags
377
+ * - Validate and clean attributes
378
+ * - Escape dangerous characters
379
+ * 4. Return clean text
380
+ *
381
+ * @param text - Raw input text
382
+ * @param blockType - Type of block containing the text
383
+ * @returns Sanitized text safe for email
384
+ */
385
+ export function sanitizeTextContent(text, blockType) {
386
+ if (!text)
387
+ return '';
388
+ const { text: textWithPlaceholders, tokens } = extractPersonalizationTokens(text);
389
+ const config = BLOCK_SANITIZATION_CONFIG[blockType];
390
+ let sanitized;
391
+ if (config.stripAllHTMLExcept.length === 0) {
392
+ const plain = stripAllHtml(textWithPlaceholders);
393
+ sanitized = config.escapeUnsafeCharacters ? escapeHtml(plain) : plain;
394
+ }
395
+ else {
396
+ sanitized = sanitizeHtml(textWithPlaceholders, config.stripAllHTMLExcept);
397
+ }
398
+ return restorePersonalizationTokens(sanitized, tokens);
399
+ }
400
+ /**
401
+ * Validate URL for href/src attributes
402
+ * Ensures protocols are safe and format is valid
403
+ *
404
+ * @param url - URL to validate
405
+ * @param requireHttps - If true, reject http://
406
+ * @returns true if valid
407
+ */
408
+ export function isValidUrl(url, requireHttps = false) {
409
+ if (!url || typeof url !== 'string')
410
+ return false;
411
+ try {
412
+ const urlObj = new URL(url);
413
+ const protocol = urlObj.protocol.slice(0, -1); // Remove trailing :
414
+ if (requireHttps) {
415
+ return protocol === 'https';
416
+ }
417
+ return ['http', 'https', 'mailto'].includes(protocol);
418
+ }
419
+ catch {
420
+ // Not a valid URL
421
+ return false;
422
+ }
423
+ }
424
+ /**
425
+ * Sanitize button label
426
+ * Buttons don't support HTML - only plain text
427
+ *
428
+ * @param label - Button label text
429
+ * @returns Sanitized label
430
+ */
431
+ export function sanitizeButtonLabel(label) {
432
+ if (!label)
433
+ return '';
434
+ // Strip all HTML
435
+ const plain = stripAllHtml(label);
436
+ // Trim and escape
437
+ return escapeHtml(plain.trim());
438
+ }
439
+ /**
440
+ * Sanitize image alt text
441
+ * Should be plain text only
442
+ *
443
+ * @param alt - Alt text
444
+ * @returns Sanitized alt text
445
+ */
446
+ export function sanitizeImageAlt(alt) {
447
+ if (!alt)
448
+ return '';
449
+ // Strip HTML and escape
450
+ const plain = stripAllHtml(alt);
451
+ return escapeHtml(plain.trim());
452
+ }
453
+ /**
454
+ * Batch sanitization for entire block object
455
+ * Applies type-specific sanitization to all text fields
456
+ *
457
+ * PSEUDOCODE:
458
+ * 1. Get block type
459
+ * 2. For each text field in block:
460
+ * - Apply appropriate sanitization function
461
+ * - Validate URLs if applicable
462
+ * - Check color formats
463
+ * 3. Return sanitized block copy
464
+ *
465
+ * @param block - Block object to sanitize
466
+ * @returns New block with sanitized content
467
+ */
468
+ export function sanitizeBlock(block) {
469
+ const sanitized = { ...block };
470
+ switch (block.type) {
471
+ case 'title':
472
+ case 'paragraph':
473
+ case 'highlight-box':
474
+ if ('content' in sanitized) {
475
+ sanitized.content = sanitizeTextContent(sanitized.content, block.type);
476
+ }
477
+ break;
478
+ case 'button':
479
+ if ('label' in sanitized) {
480
+ sanitized.label = sanitizeButtonLabel(sanitized.label);
481
+ }
482
+ if ('href' in sanitized) {
483
+ if (!isValidUrl(sanitized.href)) {
484
+ throw new Error(`Invalid URL in button: ${sanitized.href}`);
485
+ }
486
+ }
487
+ break;
488
+ case 'image':
489
+ if ('alt' in sanitized) {
490
+ sanitized.alt = sanitizeImageAlt(sanitized.alt);
491
+ }
492
+ if ('src' in sanitized) {
493
+ if (!isValidUrl(sanitized.src, true)) {
494
+ // Images must use HTTPS
495
+ throw new Error(`Invalid HTTPS URL for image: ${sanitized.src}`);
496
+ }
497
+ }
498
+ break;
499
+ // Dividers don't have text content
500
+ case 'divider':
501
+ break;
502
+ }
503
+ return sanitized;
504
+ }
505
+ /**
506
+ * Get detailed sanitization report
507
+ * Useful for debugging and user feedback
508
+ */
509
+ export function getSanitizationReport(original, blockType) {
510
+ const warnings = [];
511
+ const sanitized = sanitizeTextContent(original, blockType);
512
+ // Count removed tags
513
+ const tagRegex = /<[^>]*>/g;
514
+ const config = BLOCK_SANITIZATION_CONFIG[blockType];
515
+ const allowedTagSet = new Set(config.stripAllHTMLExcept);
516
+ let removedCount = 0;
517
+ const matches = original.matchAll(tagRegex);
518
+ for (const match of matches) {
519
+ const tag = match[0];
520
+ const tagName = tag.match(/<\/?([a-zA-Z][a-zA-Z0-9]*)/)?.[1]?.toLowerCase();
521
+ if (tagName && !allowedTagSet.has(tagName)) {
522
+ removedCount++;
523
+ warnings.push(`Removed disallowed tag: ${tag}`);
524
+ }
525
+ }
526
+ return {
527
+ success: removedCount === 0,
528
+ original,
529
+ sanitized,
530
+ removedCount,
531
+ warnings,
532
+ };
533
+ }