@zyther/tzmail 0.1.4 → 0.5.0

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 (132) hide show
  1. package/README.md +411 -78
  2. package/dist/core/enums/theme.enum.d.ts +11 -1
  3. package/dist/core/enums/theme.enum.d.ts.map +1 -1
  4. package/dist/core/enums/theme.enum.js +10 -0
  5. package/dist/core/interfaces/email.interface.d.ts +95 -5
  6. package/dist/core/interfaces/email.interface.d.ts.map +1 -1
  7. package/dist/core/interfaces/template.interface.d.ts +12 -1
  8. package/dist/core/interfaces/template.interface.d.ts.map +1 -1
  9. package/dist/factories/email-factory.d.ts +32 -6
  10. package/dist/factories/email-factory.d.ts.map +1 -1
  11. package/dist/factories/email-factory.js +110 -6
  12. package/dist/factories/template-factory.d.ts.map +1 -1
  13. package/dist/factories/template-factory.js +170 -60
  14. package/dist/index.d.ts +16 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +23 -1
  17. package/dist/providers/mailgun.transport.d.ts +10 -0
  18. package/dist/providers/mailgun.transport.d.ts.map +1 -0
  19. package/dist/providers/mailgun.transport.js +85 -0
  20. package/dist/providers/nodemailer.transport.d.ts +11 -0
  21. package/dist/providers/nodemailer.transport.d.ts.map +1 -0
  22. package/dist/providers/nodemailer.transport.js +23 -0
  23. package/dist/providers/provider-utils.d.ts +17 -0
  24. package/dist/providers/provider-utils.d.ts.map +1 -0
  25. package/dist/providers/provider-utils.js +89 -0
  26. package/dist/providers/provider.interface.d.ts +30 -0
  27. package/dist/providers/provider.interface.d.ts.map +1 -0
  28. package/dist/providers/provider.interface.js +2 -0
  29. package/dist/providers/sendgrid.transport.d.ts +10 -0
  30. package/dist/providers/sendgrid.transport.d.ts.map +1 -0
  31. package/dist/providers/sendgrid.transport.js +76 -0
  32. package/dist/queues/bullmq-email-queue.d.ts +153 -0
  33. package/dist/queues/bullmq-email-queue.d.ts.map +1 -0
  34. package/dist/queues/bullmq-email-queue.js +292 -0
  35. package/dist/services/attachment.service.d.ts +15 -37
  36. package/dist/services/attachment.service.d.ts.map +1 -1
  37. package/dist/services/attachment.service.js +58 -46
  38. package/dist/services/email.service.d.ts +22 -22
  39. package/dist/services/email.service.d.ts.map +1 -1
  40. package/dist/services/email.service.js +117 -33
  41. package/dist/services/template.service.d.ts +24 -111
  42. package/dist/services/template.service.d.ts.map +1 -1
  43. package/dist/services/template.service.js +273 -362
  44. package/dist/templates/base/color-accessibility.d.ts +4 -0
  45. package/dist/templates/base/color-accessibility.d.ts.map +1 -0
  46. package/dist/templates/base/color-accessibility.js +38 -0
  47. package/dist/templates/base/template-builder.d.ts +4 -1
  48. package/dist/templates/base/template-builder.d.ts.map +1 -1
  49. package/dist/templates/base/template-builder.js +276 -527
  50. package/dist/templates/themes/aurora.theme.d.ts +12 -0
  51. package/dist/templates/themes/aurora.theme.d.ts.map +1 -0
  52. package/dist/templates/themes/aurora.theme.js +88 -0
  53. package/dist/templates/themes/corporate.theme.d.ts +2 -1
  54. package/dist/templates/themes/corporate.theme.d.ts.map +1 -1
  55. package/dist/templates/themes/corporate.theme.js +139 -102
  56. package/dist/templates/themes/editorial.theme.d.ts +12 -0
  57. package/dist/templates/themes/editorial.theme.d.ts.map +1 -0
  58. package/dist/templates/themes/editorial.theme.js +90 -0
  59. package/dist/templates/themes/forest.theme.d.ts +12 -0
  60. package/dist/templates/themes/forest.theme.d.ts.map +1 -0
  61. package/dist/templates/themes/forest.theme.js +89 -0
  62. package/dist/templates/themes/glass.theme.d.ts +12 -0
  63. package/dist/templates/themes/glass.theme.d.ts.map +1 -0
  64. package/dist/templates/themes/glass.theme.js +88 -0
  65. package/dist/templates/themes/lavender.theme.d.ts +12 -0
  66. package/dist/templates/themes/lavender.theme.d.ts.map +1 -0
  67. package/dist/templates/themes/lavender.theme.js +91 -0
  68. package/dist/templates/themes/midnight.theme.d.ts +12 -0
  69. package/dist/templates/themes/midnight.theme.d.ts.map +1 -0
  70. package/dist/templates/themes/midnight.theme.js +91 -0
  71. package/dist/templates/themes/minimal.theme.d.ts +2 -1
  72. package/dist/templates/themes/minimal.theme.d.ts.map +1 -1
  73. package/dist/templates/themes/minimal.theme.js +142 -107
  74. package/dist/templates/themes/modern.theme.d.ts +2 -1
  75. package/dist/templates/themes/modern.theme.d.ts.map +1 -1
  76. package/dist/templates/themes/modern.theme.js +109 -74
  77. package/dist/templates/themes/monokai.theme.d.ts +2 -1
  78. package/dist/templates/themes/monokai.theme.d.ts.map +1 -1
  79. package/dist/templates/themes/monokai.theme.js +102 -66
  80. package/dist/templates/themes/neobrutal.theme.d.ts +12 -0
  81. package/dist/templates/themes/neobrutal.theme.d.ts.map +1 -0
  82. package/dist/templates/themes/neobrutal.theme.js +91 -0
  83. package/dist/templates/themes/ocean.theme.d.ts +12 -0
  84. package/dist/templates/themes/ocean.theme.d.ts.map +1 -0
  85. package/dist/templates/themes/ocean.theme.js +90 -0
  86. package/dist/templates/themes/pastel.theme.d.ts +12 -0
  87. package/dist/templates/themes/pastel.theme.d.ts.map +1 -0
  88. package/dist/templates/themes/pastel.theme.js +89 -0
  89. package/dist/templates/themes/sunset.theme.d.ts +12 -0
  90. package/dist/templates/themes/sunset.theme.d.ts.map +1 -0
  91. package/dist/templates/themes/sunset.theme.js +90 -0
  92. package/dist/templates/themes/system.theme.d.ts +2 -1
  93. package/dist/templates/themes/system.theme.d.ts.map +1 -1
  94. package/dist/templates/themes/system.theme.js +81 -47
  95. package/dist/templates/themes/theme.interface.d.ts +48 -0
  96. package/dist/templates/themes/theme.interface.d.ts.map +1 -1
  97. package/package.json +36 -14
  98. package/src/core/enums/template-part.enum.ts +7 -0
  99. package/src/core/enums/theme.enum.ts +18 -0
  100. package/src/core/interfaces/email.interface.ts +116 -0
  101. package/src/core/interfaces/template.interface.ts +71 -0
  102. package/src/factories/email-factory.ts +208 -0
  103. package/src/factories/template-factory.ts +201 -0
  104. package/src/index.ts +53 -0
  105. package/src/providers/mailgun.transport.ts +80 -0
  106. package/src/providers/nodemailer.transport.ts +20 -0
  107. package/src/providers/provider-utils.ts +91 -0
  108. package/src/providers/provider.interface.ts +26 -0
  109. package/src/providers/sendgrid.transport.ts +80 -0
  110. package/src/queues/bullmq-email-queue.ts +418 -0
  111. package/src/services/attachment.service.ts +98 -0
  112. package/src/services/email.service.ts +151 -0
  113. package/src/services/template.service.ts +431 -0
  114. package/src/templates/base/color-accessibility.ts +31 -0
  115. package/src/templates/base/template-builder.ts +438 -0
  116. package/src/templates/themes/aurora.theme.ts +91 -0
  117. package/src/templates/themes/corporate.theme.ts +156 -0
  118. package/src/templates/themes/editorial.theme.ts +93 -0
  119. package/src/templates/themes/forest.theme.ts +92 -0
  120. package/src/templates/themes/glass.theme.ts +91 -0
  121. package/src/templates/themes/lavender.theme.ts +94 -0
  122. package/src/templates/themes/midnight.theme.ts +94 -0
  123. package/src/templates/themes/minimal.theme.ts +158 -0
  124. package/src/templates/themes/modern.theme.ts +124 -0
  125. package/src/templates/themes/monokai.theme.ts +117 -0
  126. package/src/templates/themes/neobrutal.theme.ts +94 -0
  127. package/src/templates/themes/ocean.theme.ts +93 -0
  128. package/src/templates/themes/pastel.theme.ts +92 -0
  129. package/src/templates/themes/sunset.theme.ts +93 -0
  130. package/src/templates/themes/system.theme.ts +92 -0
  131. package/src/templates/themes/theme.interface.ts +95 -0
  132. package/src/tests/template.service.test.ts +1 -0
@@ -1,506 +1,417 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TemplateService = void 0;
4
+ const node_crypto_1 = require("node:crypto");
4
5
  const template_factory_1 = require("../factories/template-factory");
5
6
  const theme_enum_1 = require("../core/enums/theme.enum");
7
+ const VALID_THEMES = Object.values(theme_enum_1.ThemeType);
8
+ const SAFE_LANGUAGE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/;
9
+ const SAFE_COLOR = /^(#[\da-f]{3,8}|[a-z]{1,24}|(?:rgb|rgba|hsl|hsla)\([\d.%\s,+/-]+\))$/i;
10
+ const SAFE_COLOR_NAMES = new Set(('transparent inherit currentcolor aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgrey darkgreen darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray grey green greenyellow honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgrey lightgreen lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen').split(' '));
11
+ const SOCIAL_PLATFORMS = new Set([
12
+ 'facebook', 'twitter', 'linkedin', 'github', 'instagram', 'youtube', 'tiktok', 'pinterest', 'reddit',
13
+ 'gitlab', 'stackoverflow', 'medium', 'dribbble', 'behance', 'telegram'
14
+ ]);
15
+ function cloneConfig(value) {
16
+ if (value === undefined || value === null)
17
+ return value;
18
+ return JSON.parse(JSON.stringify(value));
19
+ }
20
+ function canonicalize(value) {
21
+ if (Array.isArray(value))
22
+ return value.map(canonicalize);
23
+ if (value && typeof value === 'object') {
24
+ return Object.keys(value).sort().reduce((result, key) => {
25
+ result[key] = canonicalize(value[key]);
26
+ return result;
27
+ }, {});
28
+ }
29
+ return value;
30
+ }
31
+ function isSafeColor(value) {
32
+ if (typeof value !== 'string' || !SAFE_COLOR.test(value.trim()))
33
+ return false;
34
+ const normalized = value.trim().toLowerCase();
35
+ return normalized.startsWith('#') || normalized.startsWith('rgb') || normalized.startsWith('hsl') || SAFE_COLOR_NAMES.has(normalized);
36
+ }
37
+ function hasTemplatePlaceholder(value) {
38
+ return typeof value === 'string' && /\{\{\s*[A-Za-z0-9_.-]+\s*\}\}/.test(value);
39
+ }
40
+ function assertSafeUrl(value, allowMailto, field) {
41
+ if (typeof value !== 'string' || !value.trim() || /[\u0000-\u0020"'<>\\]/.test(value)) {
42
+ throw new Error(`${field} must be a valid URL`);
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = new URL(value);
47
+ }
48
+ catch {
49
+ throw new Error(`${field} must be an absolute URL`);
50
+ }
51
+ const allowed = allowMailto ? ['http:', 'https:', 'mailto:'] : ['http:', 'https:'];
52
+ if (!allowed.includes(parsed.protocol.toLowerCase()))
53
+ throw new Error(`${field} uses an unsafe URL scheme`);
54
+ }
6
55
  /**
7
- * TemplateService - Gerencia a criação, renderização e gerenciamento de templates de email
8
- *
9
- * Métodos:
10
- * - createTemplate(themeType, variant, config, options): ITemplate
11
- * Cria um novo template com as configurações especificadas.
12
- *
13
- * - cloneTemplate(template, modifications): ITemplate
14
- * Cria um template a partir de um template existente (clone).
15
- *
16
- * - renderTemplate(template, data, options): Promise<string>
17
- * Renderiza um template com os dados fornecidos.
18
- *
19
- * - previewTemplate(themeType, variant, config, data): Promise<string>
20
- * Pré-visualiza um template sem enviar email.
21
- *
22
- * - getThemeInfo(themeType): object
23
- * Obtém informações sobre um tema específico.
24
- *
25
- * - listThemes(): ThemeType[]
26
- * Lista todos os temas disponíveis.
27
- *
28
- * - getTemplateStats(): object
29
- * Obtém estatísticas de uso dos templates.
30
- *
31
- * - clearCache(themeType?): number
32
- * Limpa o cache de templates, opcionalmente por tema.
33
- *
34
- * - getTemplateHistory(templateId): ITemplate[]
35
- * Obtém histórico de versões de um template.
36
- *
37
- * - restoreTemplateVersion(templateId, versionIndex): ITemplate | null
38
- * Restaura uma versão anterior do template.
39
- *
40
- * Exemplo de uso:
41
- *
42
- * const templateService = new TemplateService();
43
- * const myTemplate = templateService.createTemplate(ThemeType.MODERN, 'light', { header: { show: true } });
44
- * const html = await templateService.renderTemplate(myTemplate, { username: 'John' });
45
- * console.log(html);
56
+ * TemplateService - criação, renderização e gestão de templates de email.
57
+ * Cache e histórico mantêm limites finitos e retornam snapshots defensivos.
46
58
  */
47
59
  class TemplateService {
60
+ templateCache = new Map();
61
+ templateHistory = new Map();
62
+ cacheRequests = 0;
63
+ cacheHits = 0;
64
+ options;
65
+ cleanupTimer;
48
66
  constructor(options = {}) {
49
- this.options = options;
50
- this.templateCache = new Map();
51
- this.defaultTTL = 3600;
52
- this.templateHistory = new Map();
53
67
  this.options = {
54
68
  cache: true,
55
69
  cacheTTL: 3600,
56
70
  validateConfig: true,
57
- minify: true,
71
+ minify: false,
58
72
  preview: false,
73
+ maxCacheEntries: 100,
74
+ maxHistoryEntries: 100,
75
+ maxVersionsPerTemplate: 10,
59
76
  ...options
60
77
  };
61
- setInterval(() => this.cleanExpiredCache(), 3600000);
78
+ this.validateLimit(this.options.maxCacheEntries, 'maxCacheEntries');
79
+ this.validateLimit(this.options.maxHistoryEntries, 'maxHistoryEntries');
80
+ this.validateLimit(this.options.maxVersionsPerTemplate, 'maxVersionsPerTemplate');
81
+ this.cleanupTimer = setInterval(() => this.cleanExpiredCache(), 3600000);
82
+ this.cleanupTimer.unref?.();
62
83
  }
63
- /**
64
- * Cria um novo template com as configurações especificadas
65
- */
66
84
  createTemplate(themeType, variant, config, options) {
67
- // Validar configuração se necessário
68
- if (this.options.validateConfig || (options === null || options === void 0 ? void 0 : options.validateConfig)) {
85
+ this.assertThemeAndVariant(themeType, variant);
86
+ if (!config || typeof config !== 'object' || Array.isArray(config))
87
+ throw new Error('Template config must be an object');
88
+ if (options?.validateConfig ?? this.options.validateConfig)
69
89
  this.validateTemplateConfig(config);
70
- }
71
- // Gerar ID único para o template
72
90
  const templateId = this.generateTemplateId(themeType, variant, config);
73
- // ckeck cache
74
- if ((this.options.cache || (options === null || options === void 0 ? void 0 : options.cache)) && this.templateCache.has(templateId)) {
91
+ const cacheEnabled = options?.cache ?? this.options.cache;
92
+ const ttlSeconds = options?.cacheTTL ?? this.options.cacheTTL;
93
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds < 0)
94
+ throw new Error('cacheTTL must be a finite non-negative number');
95
+ if (options?.maxCacheEntries !== undefined)
96
+ this.validateLimit(options.maxCacheEntries, 'maxCacheEntries');
97
+ if (options?.maxHistoryEntries !== undefined)
98
+ this.validateLimit(options.maxHistoryEntries, 'maxHistoryEntries');
99
+ if (options?.maxVersionsPerTemplate !== undefined)
100
+ this.validateLimit(options.maxVersionsPerTemplate, 'maxVersionsPerTemplate');
101
+ const useCache = cacheEnabled && ttlSeconds > 0;
102
+ if (useCache) {
103
+ this.cacheRequests++;
75
104
  const cached = this.templateCache.get(templateId);
76
- cached.hits++;
77
- return cached.template;
105
+ if (cached && cached.expiresAt.getTime() > Date.now()) {
106
+ cached.hits++;
107
+ this.cacheHits++;
108
+ // Atualiza ordem para limitar o mapa por LRU aproximado.
109
+ this.templateCache.delete(templateId);
110
+ this.templateCache.set(templateId, cached);
111
+ return this.copyTemplate(cached.template);
112
+ }
113
+ if (cached)
114
+ this.templateCache.delete(templateId);
78
115
  }
79
- // Criar template
80
- const template = template_factory_1.TemplateFactory.createTemplate(themeType, variant, config);
81
- // add métodos adicionais ao template
82
- const enhancedTemplate = this.enhanceTemplate(template, templateId);
83
- // store em cache
84
- if (this.options.cache || (options === null || options === void 0 ? void 0 : options.cache)) {
85
- const ttl = ((options === null || options === void 0 ? void 0 : options.cacheTTL) || this.options.cacheTTL || this.defaultTTL) * 1000;
116
+ const safeConfig = cloneConfig(config);
117
+ const template = template_factory_1.TemplateFactory.createTemplate(themeType, variant, safeConfig);
118
+ const enhanced = this.enhanceTemplate(template, templateId);
119
+ if (useCache) {
86
120
  this.templateCache.set(templateId, {
87
- template: enhancedTemplate,
121
+ template: enhanced,
88
122
  createdAt: new Date(),
89
- expiresAt: new Date(Date.now() + ttl),
90
- hits: 1
123
+ expiresAt: new Date(Date.now() + ttlSeconds * 1000),
124
+ hits: 0
91
125
  });
126
+ this.trimCache(options?.maxCacheEntries ?? this.options.maxCacheEntries);
92
127
  }
93
- // reg no histórico
94
- this.addToHistory(templateId, enhancedTemplate);
95
- return enhancedTemplate;
128
+ this.addToHistory(templateId, enhanced, options);
129
+ return this.copyTemplate(enhanced);
96
130
  }
97
- /**
98
- * Cria um template a partir de um template existente (clone)
99
- */
100
131
  cloneTemplate(template, modifications) {
101
- const newConfig = {
102
- ...template.config,
103
- ...modifications
104
- };
132
+ const newConfig = { ...cloneConfig(template.config), ...cloneConfig(modifications || {}) };
105
133
  return this.createTemplate(template.theme, template.variant, newConfig);
106
134
  }
107
- /**
108
- * Renderiza um template com os dados fornecidos
109
- */
110
135
  async renderTemplate(template, data, options) {
111
136
  try {
112
- // add metadados para preview se necessário
137
+ // _meta é reservado e sempre produzido internamente, nunca aceito do chamador.
113
138
  const renderData = {
114
- ...data,
139
+ ...(data && typeof data === 'object' ? data : {}),
115
140
  _meta: {
116
- isPreview: (options === null || options === void 0 ? void 0 : options.preview) || this.options.preview,
141
+ isPreview: options?.preview ?? this.options.preview,
117
142
  renderDate: new Date().toISOString(),
118
143
  templateName: template.name,
119
144
  theme: template.theme,
120
145
  variant: template.variant
121
146
  }
122
147
  };
123
- // render template
124
- let html = await template.render(renderData);
125
- // Minificar HTML se necessário
126
- if (this.options.minify) {
127
- html = this.minifyHtml(html);
128
- }
129
- console.log(`Template ${template.name} rendered successfully.`);
130
- console.log(`Render metadata:`, renderData._meta);
131
- console.log(html);
132
- return html;
148
+ const html = await template.render(renderData);
149
+ // Minificação global removida: colapsar whitespace corrompia texto pré-formatado e <code>.
150
+ return this.options.minify ? this.minifyHtml(html) : html;
133
151
  }
134
152
  catch (error) {
135
- throw new Error(`Failed to render template: ${error}`);
153
+ throw new Error(`Failed to render template: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
136
154
  }
137
155
  }
138
- /**
139
- * Pré-visualiza um template sem enviar email
140
- */
141
156
  async previewTemplate(themeType, variant, config, data) {
142
157
  const template = this.createTemplate(themeType, variant, config, { cache: false });
143
158
  return this.renderTemplate(template, data, { preview: true });
144
159
  }
145
- /**
146
- * Obtém informações sobre um tema específico
147
- */
148
160
  getThemeInfo(themeType) {
149
161
  const info = template_factory_1.TemplateFactory.getThemeInfo(themeType);
150
162
  const theme = template_factory_1.TemplateFactory.getTheme(themeType);
151
- return {
152
- ...info,
153
- availableVariants: ['light', 'dark'],
154
- defaultConfig: this.getDefaultConfigForTheme(themeType, theme)
155
- };
156
- }
157
- /**
158
- * Lista todos os temas disponíveis
159
- */
160
- listThemes() {
161
- return template_factory_1.TemplateFactory.listThemes();
163
+ if (!info || !theme)
164
+ throw new Error(`Theme ${themeType} not found`);
165
+ return { ...info, availableVariants: ['light', 'dark'], defaultConfig: this.getDefaultConfigForTheme(themeType) };
162
166
  }
163
- /**
164
- * Obtém estatísticas de uso dos templates
165
- */
167
+ listThemes() { return template_factory_1.TemplateFactory.listThemes(); }
166
168
  getTemplateStats() {
167
- const templatesByTheme = {
168
- [theme_enum_1.ThemeType.SYSTEM]: 0,
169
- [theme_enum_1.ThemeType.MONOKAI]: 0,
170
- [theme_enum_1.ThemeType.MODERN]: 0,
171
- [theme_enum_1.ThemeType.CORPORATE]: 0,
172
- [theme_enum_1.ThemeType.MINIMAL]: 0
173
- };
174
- let totalHits = 0;
169
+ const templatesByTheme = Object.fromEntries(Object.values(theme_enum_1.ThemeType).map((theme) => [theme, 0]));
175
170
  for (const cache of this.templateCache.values()) {
176
- const theme = cache.template.theme;
177
- templatesByTheme[theme]++;
178
- totalHits += cache.hits;
171
+ templatesByTheme[cache.template.theme]++;
179
172
  }
180
173
  const totalCached = this.templateCache.size;
181
- const totalInHistory = this.templateHistory.size;
182
174
  return {
183
- totalTemplates: totalInHistory,
175
+ totalTemplates: this.templateHistory.size,
184
176
  cachedTemplates: totalCached,
185
- totalHits,
177
+ totalHits: this.cacheHits,
178
+ cacheRequests: this.cacheRequests,
186
179
  templatesByTheme,
187
- cacheHitRate: totalCached > 0 ? (totalHits / totalCached) * 100 : 0
180
+ cacheHitRate: this.cacheRequests > 0 ? (this.cacheHits / this.cacheRequests) * 100 : 0
188
181
  };
189
182
  }
190
- /**
191
- * Limpa o cache de templates
192
- */
193
183
  clearCache(themeType) {
194
184
  let cleared = 0;
195
- if (themeType) {
196
- for (const [id, cache] of this.templateCache.entries()) {
197
- if (cache.template.theme === themeType) {
198
- this.templateCache.delete(id);
199
- cleared++;
200
- }
185
+ for (const [id, cache] of this.templateCache.entries()) {
186
+ if (!themeType || cache.template.theme === themeType) {
187
+ this.templateCache.delete(id);
188
+ cleared++;
201
189
  }
202
190
  }
203
- else {
204
- cleared = this.templateCache.size;
205
- this.templateCache.clear();
206
- }
207
191
  return cleared;
208
192
  }
209
- /**
210
- * Obtém histórico de versões de um template
211
- */
212
193
  getTemplateHistory(templateId) {
213
- return this.templateHistory.get(templateId) || [];
194
+ return (this.templateHistory.get(templateId) || []).map(template => this.copyTemplate(template));
214
195
  }
215
- /**
216
- * Restaura uma versão anterior do template
217
- */
218
196
  restoreTemplateVersion(templateId, versionIndex) {
219
197
  const history = this.templateHistory.get(templateId);
220
- if (!history || versionIndex >= history.length) {
198
+ if (!history || !Number.isInteger(versionIndex) || versionIndex < 0 || versionIndex >= history.length)
221
199
  return null;
222
- }
223
200
  const oldTemplate = history[versionIndex];
224
201
  return this.createTemplate(oldTemplate.theme, oldTemplate.variant, oldTemplate.config, { cache: false });
225
202
  }
226
- /**
227
- * Valida a configuração do template
228
- */
203
+ /** Encerrar o timer de limpeza quando a instância não for mais necessária. */
204
+ dispose() { clearInterval(this.cleanupTimer); }
229
205
  validateTemplateConfig(config) {
230
- var _a, _b, _c;
231
206
  const errors = [];
232
- // Validar header
233
- if (((_a = config.header) === null || _a === void 0 ? void 0 : _a.show) && ((_b = config.header) === null || _b === void 0 ? void 0 : _b.logo)) {
234
- if (config.header.logo.type === 'image' && !config.header.logo.imageUrl) {
235
- errors.push('Logo image requires imageUrl');
236
- }
237
- if (config.header.logo.type === 'text' && !config.header.logo.text) {
238
- errors.push('Logo text requires text content');
239
- }
207
+ const fail = (fn) => { try {
208
+ fn();
240
209
  }
241
- // Validar footer
242
- if ((_c = config.footer) === null || _c === void 0 ? void 0 : _c.show) {
243
- if (config.footer.links) {
244
- for (const link of config.footer.links) {
245
- if (!link.text || !link.url) {
246
- errors.push('Footer links require both text and url');
247
- }
248
- }
249
- }
210
+ catch (error) {
211
+ errors.push(error instanceof Error ? error.message : String(error));
212
+ } };
213
+ const enumValue = (value, allowed, label) => {
214
+ if (value !== undefined && !allowed.includes(value))
215
+ errors.push(`Invalid ${label}: ${value}`);
216
+ };
217
+ if (config.language !== undefined && (typeof config.language !== 'string' || config.language.length > 63 || !SAFE_LANGUAGE_TAG.test(config.language))) {
218
+ errors.push('Invalid template language tag');
250
219
  }
251
- // Validar layout
252
- const validLayouts = ['full', 'minimal'];
253
- if (config.layout && !validLayouts.includes(config.layout)) {
254
- errors.push(`Invalid layout: ${config.layout}. Must be one of: ${validLayouts.join(', ')}`);
220
+ enumValue(config.direction, ['ltr', 'rtl', 'auto'], 'template text direction');
221
+ enumValue(config.linkDecoration, ['none', 'underline'], 'template linkDecoration');
222
+ if (config.preheader !== undefined && (typeof config.preheader !== 'string' || config.preheader.length > 500 || /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(config.preheader))) {
223
+ errors.push('preheader must be a string of at most 500 printable characters');
255
224
  }
256
- // Validar spacing
257
- const validSpacing = ['compact', 'normal', 'relaxed'];
258
- if (config.spacing && !validSpacing.includes(config.spacing)) {
259
- errors.push(`Invalid spacing: ${config.spacing}. Must be one of: ${validSpacing.join(', ')}`);
225
+ enumValue(config.layout, ['full', 'minimal'], 'layout');
226
+ enumValue(config.spacing, ['compact', 'normal', 'relaxed'], 'spacing');
227
+ enumValue(config.borderRadius, ['none', 'small', 'medium', 'large'], 'borderRadius');
228
+ if (config.header) {
229
+ if (typeof config.header.show !== 'boolean')
230
+ errors.push('header.show must be a boolean');
231
+ if (config.header.logo) {
232
+ enumValue(config.header.logo.type, ['text', 'image'], 'header.logo.type');
233
+ enumValue(config.header.logo.size, ['small', 'medium', 'large'], 'header.logo.size');
234
+ if (config.header.logo.type === 'image' && !hasTemplatePlaceholder(config.header.logo.imageUrl))
235
+ fail(() => assertSafeUrl(config.header.logo.imageUrl, false, 'header.logo.imageUrl'));
236
+ if (config.header.logo.type === 'text' && typeof config.header.logo.text !== 'string')
237
+ errors.push('header.logo.text must be a string');
238
+ }
239
+ if (config.header.backgroundColor !== undefined && !isSafeColor(config.header.backgroundColor))
240
+ errors.push('Invalid header.backgroundColor');
241
+ if (config.header.textColor !== undefined && !isSafeColor(config.header.textColor))
242
+ errors.push('Invalid header.textColor');
260
243
  }
261
- // Validar borderRadius
262
- const validBorderRadius = ['none', 'small', 'medium', 'large'];
263
- if (config.borderRadius && !validBorderRadius.includes(config.borderRadius)) {
264
- errors.push(`Invalid borderRadius: ${config.borderRadius}. Must be one of: ${validBorderRadius.join(', ')}`);
244
+ const body = config.body;
245
+ if (body) {
246
+ enumValue(body.alignment, ['left', 'center', 'right'], 'body.alignment');
247
+ enumValue(body.buttonVariant, ['primary', 'secondary', 'success', 'danger'], 'body.buttonVariant');
248
+ if (body.fontSize !== undefined && (!Number.isFinite(body.fontSize) || body.fontSize < 8 || body.fontSize > 72))
249
+ errors.push('body.fontSize must be between 8 and 72');
250
+ for (const key of ['backgroundColor', 'textColor']) {
251
+ const color = body[key];
252
+ if (color !== undefined && !isSafeColor(color))
253
+ errors.push(`Invalid body.${key}`);
254
+ }
255
+ const buttonUrl = body.buttonUrl;
256
+ if (buttonUrl !== undefined && !hasTemplatePlaceholder(buttonUrl))
257
+ fail(() => assertSafeUrl(buttonUrl, true, 'body.buttonUrl'));
258
+ for (const key of ['title', 'message', 'content', 'buttonText']) {
259
+ if (body[key] !== undefined && typeof body[key] !== 'string')
260
+ errors.push(`body.${key} must be a string`);
261
+ }
265
262
  }
266
- if (errors.length > 0) {
267
- throw new Error(`Template configuration validation failed:\n${errors.join('\n')}`);
263
+ const footer = config.footer;
264
+ if (footer) {
265
+ if (typeof footer.show !== 'boolean')
266
+ errors.push('footer.show must be a boolean');
267
+ for (const key of ['backgroundColor', 'textColor']) {
268
+ const color = footer[key];
269
+ if (color !== undefined && !isSafeColor(color))
270
+ errors.push(`Invalid footer.${key}`);
271
+ }
272
+ for (const link of footer.links || []) {
273
+ if (!link || typeof link.text !== 'string')
274
+ errors.push('Footer links require text and URL');
275
+ else if (!hasTemplatePlaceholder(link.url))
276
+ fail(() => assertSafeUrl(link.url, true, 'footer link URL'));
277
+ }
278
+ for (const link of footer.socialLinks || []) {
279
+ if (!link || !SOCIAL_PLATFORMS.has(link.platform))
280
+ errors.push(`Invalid social platform: ${link?.platform}`);
281
+ else if (!hasTemplatePlaceholder(link.url))
282
+ fail(() => assertSafeUrl(link.url, false, 'social link URL'));
283
+ }
284
+ const unsubscribeUrl = footer.unsubscribeUrl;
285
+ if (unsubscribeUrl !== undefined && !hasTemplatePlaceholder(unsubscribeUrl))
286
+ fail(() => assertSafeUrl(unsubscribeUrl, true, 'footer.unsubscribeUrl'));
268
287
  }
288
+ if (errors.length)
289
+ throw new Error(`Template configuration validation failed:\n${errors.join('\n')}`);
290
+ }
291
+ assertThemeAndVariant(themeType, variant) {
292
+ if (!VALID_THEMES.includes(themeType))
293
+ throw new Error(`Invalid theme: ${themeType}`);
294
+ if (variant !== 'light' && variant !== 'dark')
295
+ throw new Error(`Invalid template variant: ${variant}`);
269
296
  }
270
- /**
271
- * Gera um ID único para o template baseado na configuração
272
- */
273
297
  generateTemplateId(themeType, variant, config) {
274
- const configString = JSON.stringify({
275
- theme: themeType,
276
- variant,
277
- config
278
- });
279
- // Hash simples para ID
280
- let hash = 0;
281
- for (let i = 0; i < configString.length; i++) {
282
- const char = configString.charCodeAt(i);
283
- hash = ((hash << 5) - hash) + char;
284
- hash = hash & hash;
285
- }
286
- return `${themeType}_${variant}_${Math.abs(hash)}`;
298
+ const canonical = JSON.stringify(canonicalize({ theme: themeType, variant, config }));
299
+ return `${themeType}_${variant}_${(0, node_crypto_1.createHash)('sha256').update(canonical).digest('hex')}`;
287
300
  }
288
- /**
289
- * Adiciona funcionalidades extras ao template
290
- */
291
301
  enhanceTemplate(template, templateId) {
292
302
  const enhanced = { ...template };
293
- // add método para obter versão
294
- enhanced.getVersion = () => {
295
- const history = this.templateHistory.get(templateId);
296
- return history ? history.length : 1;
297
- };
298
- // add método para obter ID
303
+ enhanced.getVersion = () => this.templateHistory.get(templateId)?.length || 1;
299
304
  enhanced.getId = () => templateId;
300
- // add método para clonar
301
- enhanced.clone = (modifications) => {
302
- return this.cloneTemplate(enhanced, modifications);
303
- };
305
+ enhanced.clone = (modifications) => this.cloneTemplate(enhanced, modifications);
304
306
  return enhanced;
305
307
  }
306
- /**
307
- * Adiciona template ao histórico
308
- */
309
- addToHistory(templateId, template) {
310
- if (!this.templateHistory.has(templateId)) {
311
- this.templateHistory.set(templateId, []);
312
- }
313
- const history = this.templateHistory.get(templateId);
308
+ copyTemplate(template) {
309
+ return { ...template, config: cloneConfig(template.config) };
310
+ }
311
+ addToHistory(templateId, template, options) {
312
+ const history = this.templateHistory.get(templateId) || [];
314
313
  history.push(template);
315
- // Manter apenas últimas 10 versões
316
- if (history.length > 10) {
314
+ const perTemplateLimit = options?.maxVersionsPerTemplate ?? this.options.maxVersionsPerTemplate;
315
+ while (history.length > perTemplateLimit)
317
316
  history.shift();
317
+ this.templateHistory.delete(templateId);
318
+ this.templateHistory.set(templateId, history);
319
+ const historyLimit = options?.maxHistoryEntries ?? this.options.maxHistoryEntries;
320
+ while (this.templateHistory.size > historyLimit) {
321
+ const oldest = this.templateHistory.keys().next().value;
322
+ if (oldest === undefined)
323
+ break;
324
+ this.templateHistory.delete(oldest);
325
+ }
326
+ }
327
+ trimCache(limit) {
328
+ while (this.templateCache.size > limit) {
329
+ const oldest = this.templateCache.keys().next().value;
330
+ if (oldest === undefined)
331
+ break;
332
+ this.templateCache.delete(oldest);
318
333
  }
319
334
  }
320
- /**
321
- * Remove templates expirados do cache
322
- */
323
335
  cleanExpiredCache() {
324
336
  const now = Date.now();
325
337
  for (const [id, cache] of this.templateCache.entries()) {
326
- if (cache.expiresAt.getTime() < now) {
338
+ if (cache.expiresAt.getTime() <= now)
327
339
  this.templateCache.delete(id);
328
- }
329
340
  }
330
341
  }
331
- /**
332
- * Minifica o HTML para reduzir tamanho
333
- */
334
- minifyHtml(html) {
335
- return html
336
- .replace(/\s+/g, ' ')
337
- .replace(/>\s+</g, '><')
338
- .replace(/<!--.*?-->/g, '')
339
- .trim();
342
+ // Mantido como transformação deliberadamente não destrutiva. HTML minification baseada em regex
343
+ // alteraria espaços de texto e conteúdo de <code>/<pre>; minify=true preserva o HTML original.
344
+ minifyHtml(html) { return html; }
345
+ validateLimit(value, name) {
346
+ if (!Number.isInteger(value) || value < 1)
347
+ throw new Error(`${name} must be a positive integer`);
340
348
  }
341
- /**
342
- * Obtém configuração padrão para um tema
343
- */
344
- getDefaultConfigForTheme(themeType, theme) {
349
+ getDefaultConfigForTheme(themeType) {
345
350
  const baseConfig = {
346
- header: {
347
- show: true,
348
- logo: {
349
- type: 'text',
350
- text: 'MyApp',
351
- size: 'medium'
352
- }
353
- },
354
- footer: {
355
- show: true,
356
- copyrightText: `© ${new Date().getFullYear()} MyApp. All rights reserved.`
357
- },
358
- layout: 'full',
359
- spacing: 'normal',
360
- borderRadius: 'medium'
351
+ header: { show: true, logo: { type: 'text', text: 'MyApp', size: 'medium' } },
352
+ footer: { show: true, copyrightText: `© ${new Date().getFullYear()} MyApp. All rights reserved.` },
353
+ layout: 'full', spacing: 'normal', borderRadius: 'medium'
361
354
  };
362
- // Ajustes específicos por tema
363
355
  switch (themeType) {
364
- case theme_enum_1.ThemeType.MONOKAI:
365
- return {
366
- ...baseConfig,
367
- borderRadius: 'small',
368
- spacing: 'normal'
369
- };
370
- case theme_enum_1.ThemeType.MODERN:
371
- return {
372
- ...baseConfig,
373
- borderRadius: 'large',
374
- spacing: 'relaxed'
375
- };
376
- case theme_enum_1.ThemeType.CORPORATE:
377
- return {
378
- ...baseConfig,
379
- borderRadius: 'small',
380
- spacing: 'normal'
381
- };
382
- case theme_enum_1.ThemeType.MINIMAL:
383
- return {
384
- ...baseConfig,
385
- borderRadius: 'none',
386
- spacing: 'relaxed'
387
- };
388
- default:
389
- return baseConfig;
356
+ case theme_enum_1.ThemeType.MONOKAI: return { ...baseConfig, borderRadius: 'small' };
357
+ case theme_enum_1.ThemeType.MODERN: return { ...baseConfig, borderRadius: 'large', spacing: 'relaxed' };
358
+ case theme_enum_1.ThemeType.CORPORATE: return { ...baseConfig, borderRadius: 'small' };
359
+ case theme_enum_1.ThemeType.MINIMAL: return { ...baseConfig, borderRadius: 'none', spacing: 'relaxed' };
360
+ default: return baseConfig;
390
361
  }
391
362
  }
392
- /**
393
- * Pré-carrega templates no cache
394
- */
395
363
  async preloadTemplates(templates) {
396
- const promises = templates.map(async ({ themeType, variant, config }) => {
397
- this.createTemplate(themeType, variant, config);
398
- });
399
- await Promise.all(promises);
364
+ await Promise.all(templates.map(({ themeType, variant, config }) => this.createTemplate(themeType, variant, config)));
400
365
  }
401
- /**
402
- * Exporta template para formato JSON
403
- */
404
366
  exportTemplate(template) {
405
- var _a, _b;
406
- return JSON.stringify({
407
- name: template.name,
408
- theme: template.theme,
409
- variant: template.variant,
410
- config: template.config,
411
- exportedAt: new Date().toISOString(),
412
- version: ((_b = (_a = template).getVersion) === null || _b === void 0 ? void 0 : _b.call(_a)) || 1
413
- }, null, 2);
367
+ return JSON.stringify({ name: template.name, theme: template.theme, variant: template.variant, config: template.config, exportedAt: new Date().toISOString(), version: template.getVersion?.() || 1 }, null, 2);
414
368
  }
415
- /**
416
- * Importa template de formato JSON
417
- */
418
369
  importTemplate(jsonData) {
419
370
  try {
420
371
  const data = JSON.parse(jsonData);
421
- if (!data.theme || !data.variant || !data.config) {
422
- throw new Error('Invalid template data: missing required fields');
372
+ if (!data || typeof data !== 'object' || !VALID_THEMES.includes(data.theme) || !['light', 'dark'].includes(data.variant) || !data.config || typeof data.config !== 'object' || Array.isArray(data.config)) {
373
+ throw new Error('Invalid template data: theme, variant, and config are required and must be valid');
423
374
  }
424
375
  return this.createTemplate(data.theme, data.variant, data.config);
425
376
  }
426
377
  catch (error) {
427
- throw new Error(`Failed to import template: ${error}`);
378
+ throw new Error(`Failed to import template: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
428
379
  }
429
380
  }
430
- /**
431
- * Gera um template de exemplo para demonstração
432
- */
433
381
  generateExampleTemplate(themeType = theme_enum_1.ThemeType.MODERN, variant = 'light') {
434
382
  const exampleConfig = {
435
- header: {
436
- show: true,
437
- logo: {
438
- type: 'text',
439
- text: 'ExampleApp',
440
- size: 'medium'
441
- },
442
- backgroundColor: variant === 'light' ? '#ffffff' : '#1a1a1a',
443
- textColor: variant === 'light' ? '#111827' : '#f9fafb'
444
- },
383
+ header: { show: true, logo: { type: 'text', text: 'ExampleApp', size: 'medium' }, backgroundColor: variant === 'light' ? '#ffffff' : '#1a1a1a', textColor: variant === 'light' ? '#111827' : '#f9fafb' },
445
384
  footer: {
446
385
  show: true,
447
- links: [
448
- { text: 'Privacy Policy', url: 'https://example.com/privacy' },
449
- { text: 'Terms of Service', url: 'https://example.com/terms' },
450
- { text: 'Contact', url: 'https://example.com/contact' }
451
- ],
452
- socialLinks: [
453
- { platform: 'twitter', url: 'https://twitter.com/example' },
454
- { platform: 'github', url: 'https://github.com/example' },
455
- { platform: 'linkedin', url: 'https://linkedin.com/company/example' }
456
- ],
457
- copyrightText: `© ${new Date().getFullYear()} ExampleApp. All rights reserved.`,
458
- unsubscribeText: 'Unsubscribe',
459
- backgroundColor: variant === 'light' ? '#f9fafb' : '#111827',
460
- textColor: variant === 'light' ? '#6b7280' : '#9ca3af'
386
+ links: [{ text: 'Privacy Policy', url: 'https://example.com/privacy' }, { text: 'Terms of Service', url: 'https://example.com/terms' }, { text: 'Contact', url: 'https://example.com/contact' }],
387
+ socialLinks: [{ platform: 'twitter', url: 'https://twitter.com/example' }, { platform: 'github', url: 'https://github.com/example' }, { platform: 'linkedin', url: 'https://linkedin.com/company/example' }],
388
+ copyrightText: `© ${new Date().getFullYear()} ExampleApp. All rights reserved.`, unsubscribeText: 'Unsubscribe',
389
+ backgroundColor: variant === 'light' ? '#f9fafb' : '#111827', textColor: variant === 'light' ? '#6b7280' : '#9ca3af'
461
390
  },
462
- layout: 'full',
463
- spacing: 'normal',
464
- borderRadius: themeType === theme_enum_1.ThemeType.MINIMAL ? 'none' : 'medium'
391
+ layout: 'full', spacing: 'normal', borderRadius: themeType === theme_enum_1.ThemeType.MINIMAL ? 'none' : 'medium'
465
392
  };
466
393
  return this.createTemplate(themeType, variant, exampleConfig);
467
394
  }
468
- /**
469
- * Valida se um template é válido
470
- */
471
395
  isValidTemplate(template) {
472
396
  try {
473
- // Verificar propriedades obrigatórias
474
- if (!template || typeof template !== 'object')
475
- return false;
476
- if (!template.name || typeof template.name !== 'string')
477
- return false;
478
- if (!template.theme || !Object.values(theme_enum_1.ThemeType).includes(template.theme))
479
- return false;
480
- if (!template.variant || !['light', 'dark'].includes(template.variant))
481
- return false;
482
- if (!template.config || typeof template.config !== 'object')
483
- return false;
484
- if (typeof template.render !== 'function')
397
+ if (!template || typeof template !== 'object' || typeof template.name !== 'string' || !VALID_THEMES.includes(template.theme) || !['light', 'dark'].includes(template.variant) || !template.config || typeof template.config !== 'object' || typeof template.render !== 'function')
485
398
  return false;
399
+ this.validateTemplateConfig(template.config);
486
400
  return true;
487
401
  }
488
402
  catch {
489
403
  return false;
490
404
  }
491
405
  }
492
- /**
493
- * Obtém estatísticas detalhadas de um template específico
494
- */
495
406
  getTemplateDetails(templateId) {
496
407
  const cacheInfo = this.templateCache.get(templateId) || null;
497
- const history = this.templateHistory.get(templateId) || [];
498
- const usageCount = (cacheInfo === null || cacheInfo === void 0 ? void 0 : cacheInfo.hits) || 0;
408
+ const history = (this.templateHistory.get(templateId) || []).map(template => this.copyTemplate(template));
409
+ const template = cacheInfo?.template || history[history.length - 1] || null;
499
410
  return {
500
- template: (cacheInfo === null || cacheInfo === void 0 ? void 0 : cacheInfo.template) || history[history.length - 1] || null,
411
+ template: template ? this.copyTemplate(template) : null,
501
412
  history,
502
- cacheInfo,
503
- usageCount
413
+ cacheInfo: cacheInfo ? { ...cacheInfo, template: this.copyTemplate(cacheInfo.template), createdAt: new Date(cacheInfo.createdAt), expiresAt: new Date(cacheInfo.expiresAt) } : null,
414
+ usageCount: cacheInfo?.hits || 0
504
415
  };
505
416
  }
506
417
  }