@zyther/tzmail 0.1.3 → 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.
- package/README.md +412 -79
- package/dist/core/enums/theme.enum.d.ts +11 -1
- package/dist/core/enums/theme.enum.d.ts.map +1 -1
- package/dist/core/enums/theme.enum.js +10 -0
- package/dist/core/interfaces/email.interface.d.ts +95 -5
- package/dist/core/interfaces/email.interface.d.ts.map +1 -1
- package/dist/core/interfaces/template.interface.d.ts +12 -1
- package/dist/core/interfaces/template.interface.d.ts.map +1 -1
- package/dist/factories/email-factory.d.ts +32 -6
- package/dist/factories/email-factory.d.ts.map +1 -1
- package/dist/factories/email-factory.js +110 -6
- package/dist/factories/template-factory.d.ts.map +1 -1
- package/dist/factories/template-factory.js +170 -60
- package/dist/index.d.ts +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -1
- package/dist/providers/mailgun.transport.d.ts +10 -0
- package/dist/providers/mailgun.transport.d.ts.map +1 -0
- package/dist/providers/mailgun.transport.js +85 -0
- package/dist/providers/nodemailer.transport.d.ts +11 -0
- package/dist/providers/nodemailer.transport.d.ts.map +1 -0
- package/dist/providers/nodemailer.transport.js +23 -0
- package/dist/providers/provider-utils.d.ts +17 -0
- package/dist/providers/provider-utils.d.ts.map +1 -0
- package/dist/providers/provider-utils.js +89 -0
- package/dist/providers/provider.interface.d.ts +30 -0
- package/dist/providers/provider.interface.d.ts.map +1 -0
- package/dist/providers/provider.interface.js +2 -0
- package/dist/providers/sendgrid.transport.d.ts +10 -0
- package/dist/providers/sendgrid.transport.d.ts.map +1 -0
- package/dist/providers/sendgrid.transport.js +76 -0
- package/dist/queues/bullmq-email-queue.d.ts +153 -0
- package/dist/queues/bullmq-email-queue.d.ts.map +1 -0
- package/dist/queues/bullmq-email-queue.js +292 -0
- package/dist/services/attachment.service.d.ts +15 -37
- package/dist/services/attachment.service.d.ts.map +1 -1
- package/dist/services/attachment.service.js +58 -46
- package/dist/services/email.service.d.ts +22 -22
- package/dist/services/email.service.d.ts.map +1 -1
- package/dist/services/email.service.js +117 -33
- package/dist/services/template.service.d.ts +24 -111
- package/dist/services/template.service.d.ts.map +1 -1
- package/dist/services/template.service.js +273 -362
- package/dist/templates/base/color-accessibility.d.ts +4 -0
- package/dist/templates/base/color-accessibility.d.ts.map +1 -0
- package/dist/templates/base/color-accessibility.js +38 -0
- package/dist/templates/base/template-builder.d.ts +4 -1
- package/dist/templates/base/template-builder.d.ts.map +1 -1
- package/dist/templates/base/template-builder.js +276 -527
- package/dist/templates/themes/aurora.theme.d.ts +12 -0
- package/dist/templates/themes/aurora.theme.d.ts.map +1 -0
- package/dist/templates/themes/aurora.theme.js +88 -0
- package/dist/templates/themes/corporate.theme.d.ts +2 -1
- package/dist/templates/themes/corporate.theme.d.ts.map +1 -1
- package/dist/templates/themes/corporate.theme.js +139 -102
- package/dist/templates/themes/editorial.theme.d.ts +12 -0
- package/dist/templates/themes/editorial.theme.d.ts.map +1 -0
- package/dist/templates/themes/editorial.theme.js +90 -0
- package/dist/templates/themes/forest.theme.d.ts +12 -0
- package/dist/templates/themes/forest.theme.d.ts.map +1 -0
- package/dist/templates/themes/forest.theme.js +89 -0
- package/dist/templates/themes/glass.theme.d.ts +12 -0
- package/dist/templates/themes/glass.theme.d.ts.map +1 -0
- package/dist/templates/themes/glass.theme.js +88 -0
- package/dist/templates/themes/lavender.theme.d.ts +12 -0
- package/dist/templates/themes/lavender.theme.d.ts.map +1 -0
- package/dist/templates/themes/lavender.theme.js +91 -0
- package/dist/templates/themes/midnight.theme.d.ts +12 -0
- package/dist/templates/themes/midnight.theme.d.ts.map +1 -0
- package/dist/templates/themes/midnight.theme.js +91 -0
- package/dist/templates/themes/minimal.theme.d.ts +2 -1
- package/dist/templates/themes/minimal.theme.d.ts.map +1 -1
- package/dist/templates/themes/minimal.theme.js +142 -107
- package/dist/templates/themes/modern.theme.d.ts +2 -1
- package/dist/templates/themes/modern.theme.d.ts.map +1 -1
- package/dist/templates/themes/modern.theme.js +109 -74
- package/dist/templates/themes/monokai.theme.d.ts +2 -1
- package/dist/templates/themes/monokai.theme.d.ts.map +1 -1
- package/dist/templates/themes/monokai.theme.js +102 -66
- package/dist/templates/themes/neobrutal.theme.d.ts +12 -0
- package/dist/templates/themes/neobrutal.theme.d.ts.map +1 -0
- package/dist/templates/themes/neobrutal.theme.js +91 -0
- package/dist/templates/themes/ocean.theme.d.ts +12 -0
- package/dist/templates/themes/ocean.theme.d.ts.map +1 -0
- package/dist/templates/themes/ocean.theme.js +90 -0
- package/dist/templates/themes/pastel.theme.d.ts +12 -0
- package/dist/templates/themes/pastel.theme.d.ts.map +1 -0
- package/dist/templates/themes/pastel.theme.js +89 -0
- package/dist/templates/themes/sunset.theme.d.ts +12 -0
- package/dist/templates/themes/sunset.theme.d.ts.map +1 -0
- package/dist/templates/themes/sunset.theme.js +90 -0
- package/dist/templates/themes/system.theme.d.ts +2 -1
- package/dist/templates/themes/system.theme.d.ts.map +1 -1
- package/dist/templates/themes/system.theme.js +81 -47
- package/dist/templates/themes/theme.interface.d.ts +48 -0
- package/dist/templates/themes/theme.interface.d.ts.map +1 -1
- package/package.json +36 -14
- package/src/core/enums/template-part.enum.ts +7 -0
- package/src/core/enums/theme.enum.ts +18 -0
- package/src/core/interfaces/email.interface.ts +116 -0
- package/src/core/interfaces/template.interface.ts +71 -0
- package/src/factories/email-factory.ts +208 -0
- package/src/factories/template-factory.ts +201 -0
- package/src/index.ts +53 -0
- package/src/providers/mailgun.transport.ts +80 -0
- package/src/providers/nodemailer.transport.ts +20 -0
- package/src/providers/provider-utils.ts +91 -0
- package/src/providers/provider.interface.ts +26 -0
- package/src/providers/sendgrid.transport.ts +80 -0
- package/src/queues/bullmq-email-queue.ts +418 -0
- package/src/services/attachment.service.ts +98 -0
- package/src/services/email.service.ts +151 -0
- package/src/services/template.service.ts +431 -0
- package/src/templates/base/color-accessibility.ts +31 -0
- package/src/templates/base/template-builder.ts +438 -0
- package/src/templates/themes/aurora.theme.ts +91 -0
- package/src/templates/themes/corporate.theme.ts +156 -0
- package/src/templates/themes/editorial.theme.ts +93 -0
- package/src/templates/themes/forest.theme.ts +92 -0
- package/src/templates/themes/glass.theme.ts +91 -0
- package/src/templates/themes/lavender.theme.ts +94 -0
- package/src/templates/themes/midnight.theme.ts +94 -0
- package/src/templates/themes/minimal.theme.ts +158 -0
- package/src/templates/themes/modern.theme.ts +124 -0
- package/src/templates/themes/monokai.theme.ts +117 -0
- package/src/templates/themes/neobrutal.theme.ts +94 -0
- package/src/templates/themes/ocean.theme.ts +93 -0
- package/src/templates/themes/pastel.theme.ts +92 -0
- package/src/templates/themes/sunset.theme.ts +93 -0
- package/src/templates/themes/system.theme.ts +92 -0
- package/src/templates/themes/theme.interface.ts +95 -0
- package/src/tests/template.service.test.ts +1 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { IAttachment } from '../core/interfaces/email.interface';
|
|
4
|
+
|
|
5
|
+
export interface IAttachmentServiceOptions {
|
|
6
|
+
/** Restrict path attachments to this directory, resolved through realpath. */
|
|
7
|
+
allowedRoot?: string;
|
|
8
|
+
/** Optional maximum byte size for path- and buffer-backed attachments. */
|
|
9
|
+
maxSizeBytes?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates Nodemailer attachment descriptors. Path attachments remain paths so
|
|
14
|
+
* Nodemailer can stream large files instead of loading them into memory.
|
|
15
|
+
* If allowedRoot is omitted, callers are responsible for supplying trusted paths.
|
|
16
|
+
*/
|
|
17
|
+
export class AttachmentService {
|
|
18
|
+
private readonly maxSizeBytes?: number;
|
|
19
|
+
|
|
20
|
+
constructor(private readonly options: IAttachmentServiceOptions = {}) {
|
|
21
|
+
if (options.maxSizeBytes !== undefined &&
|
|
22
|
+
(!Number.isFinite(options.maxSizeBytes) || options.maxSizeBytes < 0)) {
|
|
23
|
+
throw new Error('maxSizeBytes must be a finite non-negative number');
|
|
24
|
+
}
|
|
25
|
+
this.maxSizeBytes = options.maxSizeBytes;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Add an attachment as a file path, preserving streaming behavior. */
|
|
29
|
+
async addFromPath(filePath: string, options?: Partial<IAttachment>): Promise<IAttachment> {
|
|
30
|
+
if (typeof filePath !== 'string' || filePath.trim() === '') {
|
|
31
|
+
throw new Error('filePath must be a non-empty string');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let attachmentPath = filePath;
|
|
35
|
+
let stats: fs.Stats;
|
|
36
|
+
|
|
37
|
+
if (this.options.allowedRoot !== undefined) {
|
|
38
|
+
const [rootRealPath, fileRealPath] = await Promise.all([
|
|
39
|
+
fs.promises.realpath(this.options.allowedRoot),
|
|
40
|
+
fs.promises.realpath(filePath)
|
|
41
|
+
]);
|
|
42
|
+
const relativePath = path.relative(rootRealPath, fileRealPath);
|
|
43
|
+
if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
|
|
44
|
+
throw new Error('Attachment path is outside allowedRoot');
|
|
45
|
+
}
|
|
46
|
+
attachmentPath = fileRealPath;
|
|
47
|
+
stats = await fs.promises.stat(fileRealPath);
|
|
48
|
+
} else {
|
|
49
|
+
stats = await fs.promises.stat(filePath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!stats.isFile()) {
|
|
53
|
+
throw new Error('Attachment path is not a file');
|
|
54
|
+
}
|
|
55
|
+
this.assertSize(stats.size);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
filename: options?.filename || path.basename(filePath),
|
|
59
|
+
path: attachmentPath,
|
|
60
|
+
contentType: options?.contentType,
|
|
61
|
+
cid: options?.cid
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async addFromBuffer(
|
|
66
|
+
buffer: Buffer,
|
|
67
|
+
filename: string,
|
|
68
|
+
options?: Partial<IAttachment>
|
|
69
|
+
): Promise<IAttachment> {
|
|
70
|
+
if (!Buffer.isBuffer(buffer)) {
|
|
71
|
+
throw new Error('buffer must be a Buffer');
|
|
72
|
+
}
|
|
73
|
+
if (typeof filename !== 'string' || filename.trim() === '') {
|
|
74
|
+
throw new Error('filename must be a non-empty string');
|
|
75
|
+
}
|
|
76
|
+
this.assertSize(buffer.length);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
filename,
|
|
80
|
+
content: buffer,
|
|
81
|
+
contentType: options?.contentType,
|
|
82
|
+
cid: options?.cid
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** URL attachments are deliberately not fetched by this service. */
|
|
87
|
+
addFromUrl(url: string, filename: string): Promise<IAttachment> {
|
|
88
|
+
void url;
|
|
89
|
+
void filename;
|
|
90
|
+
throw new Error('addFromUrl is not supported; fetch and validate content in the application before adding an attachment');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private assertSize(size: number): void {
|
|
94
|
+
if (this.maxSizeBytes !== undefined && size > this.maxSizeBytes) {
|
|
95
|
+
throw new Error(`Attachment exceeds configured maxSizeBytes (${this.maxSizeBytes})`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { IEmailOptions, IEmailSendResult } from '../core/interfaces/email.interface';
|
|
2
|
+
import { IEmailTransport, ITransportMessage } from '../providers/provider.interface';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* EmailService sends messages through a provider-neutral transport.
|
|
6
|
+
* `send` always reports validation, render and transport failures in a typed
|
|
7
|
+
* result; callers should inspect `status` and `rejected` rather than treating
|
|
8
|
+
* any resolved promise as proof that every recipient received the message.
|
|
9
|
+
*/
|
|
10
|
+
export class EmailService {
|
|
11
|
+
constructor(
|
|
12
|
+
private transporter: IEmailTransport,
|
|
13
|
+
private defaultFrom?: string,
|
|
14
|
+
private errorStage: 'smtp' | 'provider' = 'smtp'
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validates and normalizes basic message fields, then sends the message.
|
|
19
|
+
* Header fields must be non-empty and contain no control characters; deeper
|
|
20
|
+
* address syntax and deliverability policy are delegated to the selected transport/provider.
|
|
21
|
+
*/
|
|
22
|
+
async send(options: IEmailOptions): Promise<IEmailSendResult> {
|
|
23
|
+
let mailOptions: ITransportMessage;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
mailOptions = {
|
|
27
|
+
from: this.normalizeOptionalText(options.from ?? this.defaultFrom, 'from'),
|
|
28
|
+
to: this.normalizeRecipients(options.to, 'to'),
|
|
29
|
+
cc: this.normalizeOptionalRecipients(options.cc, 'cc'),
|
|
30
|
+
bcc: this.normalizeOptionalRecipients(options.bcc, 'bcc'),
|
|
31
|
+
subject: this.normalizeRequiredText(options.subject, 'subject'),
|
|
32
|
+
text: this.normalizeBody(options.text, 'text'),
|
|
33
|
+
html: this.normalizeBody(options.html, 'html'),
|
|
34
|
+
attachments: options.attachments
|
|
35
|
+
};
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return { success: false, status: 'failure', error, errorStage: 'validation' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (mailOptions.html === undefined && options.template) {
|
|
41
|
+
try {
|
|
42
|
+
mailOptions.html = await options.template.render(options);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return { success: false, status: 'failure', error, errorStage: 'render' };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const info = await this.transporter.sendMail(mailOptions);
|
|
50
|
+
const accepted = this.recipientList(info?.accepted);
|
|
51
|
+
const rejected = this.recipientList(info?.rejected);
|
|
52
|
+
const status = rejected.length > 0
|
|
53
|
+
? (accepted.length > 0 ? 'partial' : 'failure')
|
|
54
|
+
: 'success';
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
success: status === 'success',
|
|
58
|
+
status,
|
|
59
|
+
messageId: info?.messageId,
|
|
60
|
+
response: info?.response,
|
|
61
|
+
...(info?.accepted !== undefined ? { accepted } : {}),
|
|
62
|
+
...(info?.rejected !== undefined ? { rejected } : {})
|
|
63
|
+
};
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const details = error as {
|
|
66
|
+
accepted?: unknown;
|
|
67
|
+
rejected?: unknown;
|
|
68
|
+
messageId?: string;
|
|
69
|
+
response?: string;
|
|
70
|
+
} | null;
|
|
71
|
+
const accepted = this.recipientList(details?.accepted);
|
|
72
|
+
const rejected = this.recipientList(details?.rejected);
|
|
73
|
+
const partial = accepted.length > 0 && rejected.length > 0;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
success: false,
|
|
77
|
+
status: partial ? 'partial' : 'failure',
|
|
78
|
+
error,
|
|
79
|
+
errorStage: this.errorStage,
|
|
80
|
+
...(details?.messageId !== undefined ? { messageId: details.messageId } : {}),
|
|
81
|
+
...(details?.response !== undefined ? { response: details.response } : {}),
|
|
82
|
+
...(details?.accepted !== undefined ? { accepted } : {}),
|
|
83
|
+
...(details?.rejected !== undefined ? { rejected } : {})
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Verify SMTP configuration/connectivity without delivering a message. */
|
|
89
|
+
async verify(): Promise<true> {
|
|
90
|
+
if (!this.transporter.verify) {
|
|
91
|
+
throw new Error('This API transport does not expose a non-delivery verification operation.');
|
|
92
|
+
}
|
|
93
|
+
return this.transporter.verify();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Close pooled connections during graceful application shutdown. */
|
|
97
|
+
close(): void {
|
|
98
|
+
this.transporter.close?.();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private normalizeRecipients(value: string | string[], field: string): string {
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
if (value.length === 0) {
|
|
104
|
+
throw new Error(`${field} must contain at least one recipient`);
|
|
105
|
+
}
|
|
106
|
+
const recipients = value.map((entry) => this.normalizeRequiredText(entry, field));
|
|
107
|
+
return recipients.join(', ');
|
|
108
|
+
}
|
|
109
|
+
return this.normalizeRequiredText(value, field);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private normalizeOptionalRecipients(value: string | string[] | undefined, field: string): string | undefined {
|
|
113
|
+
if (value === undefined) return undefined;
|
|
114
|
+
return this.normalizeRecipients(value, field);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private normalizeRequiredText(value: unknown, field: string): string {
|
|
118
|
+
const normalized = this.normalizeOptionalText(value, field);
|
|
119
|
+
if (normalized === undefined) {
|
|
120
|
+
throw new Error(`${field} is required`);
|
|
121
|
+
}
|
|
122
|
+
return normalized;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private normalizeOptionalText(value: unknown, field: string): string | undefined {
|
|
126
|
+
if (value === undefined) return undefined;
|
|
127
|
+
if (typeof value !== 'string') {
|
|
128
|
+
throw new Error(`${field} must be a string`);
|
|
129
|
+
}
|
|
130
|
+
const normalized = value.trim();
|
|
131
|
+
if (!normalized) {
|
|
132
|
+
throw new Error(`${field} must not be empty`);
|
|
133
|
+
}
|
|
134
|
+
if (/[\u0000-\u001f\u007f]/.test(normalized)) {
|
|
135
|
+
throw new Error(`${field} must not contain header control characters`);
|
|
136
|
+
}
|
|
137
|
+
return normalized;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private normalizeBody(value: unknown, field: string): string | undefined {
|
|
141
|
+
if (value === undefined) return undefined;
|
|
142
|
+
if (typeof value !== 'string') {
|
|
143
|
+
throw new Error(`${field} must be a string`);
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private recipientList(value: unknown): Array<string | { address: string; name: string }> {
|
|
149
|
+
return Array.isArray(value) ? value : [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { ITemplate, ITemplateConfig } from '../core/interfaces/template.interface';
|
|
3
|
+
import { TemplateFactory } from '../factories/template-factory';
|
|
4
|
+
import { ThemeType } from '../core/enums/theme.enum';
|
|
5
|
+
|
|
6
|
+
interface TemplateCache {
|
|
7
|
+
template: ITemplate;
|
|
8
|
+
createdAt: Date;
|
|
9
|
+
expiresAt: Date;
|
|
10
|
+
hits: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ITemplateServiceOptions {
|
|
14
|
+
/** Cache ligado por padrão; pode ser desligado por chamada. */
|
|
15
|
+
cache?: boolean;
|
|
16
|
+
/** TTL em segundos. Zero desabilita o cache para aquela criação; valores negativos/não finitos são inválidos. */
|
|
17
|
+
cacheTTL?: number;
|
|
18
|
+
validateConfig?: boolean;
|
|
19
|
+
/** Mantido por compatibilidade. A transformação destrutiva foi desativada. */
|
|
20
|
+
minify?: boolean;
|
|
21
|
+
preview?: boolean;
|
|
22
|
+
/** Limite de templates em cache; padrão finito de 100. */
|
|
23
|
+
maxCacheEntries?: number;
|
|
24
|
+
/** Limite de IDs de template guardados no histórico; padrão finito de 100. */
|
|
25
|
+
maxHistoryEntries?: number;
|
|
26
|
+
/** Máximo de versões por ID de template; padrão 10. */
|
|
27
|
+
maxVersionsPerTemplate?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type TemplateOptions = ITemplateServiceOptions;
|
|
31
|
+
|
|
32
|
+
const VALID_THEMES = Object.values(ThemeType);
|
|
33
|
+
const SAFE_LANGUAGE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/;
|
|
34
|
+
const SAFE_COLOR = /^(#[\da-f]{3,8}|[a-z]{1,24}|(?:rgb|rgba|hsl|hsla)\([\d.%\s,+/-]+\))$/i;
|
|
35
|
+
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(' '));
|
|
36
|
+
const SOCIAL_PLATFORMS = new Set([
|
|
37
|
+
'facebook', 'twitter', 'linkedin', 'github', 'instagram', 'youtube', 'tiktok', 'pinterest', 'reddit',
|
|
38
|
+
'gitlab', 'stackoverflow', 'medium', 'dribbble', 'behance', 'telegram'
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function cloneConfig<T>(value: T): T {
|
|
42
|
+
if (value === undefined || value === null) return value;
|
|
43
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function canonicalize(value: any): any {
|
|
47
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
48
|
+
if (value && typeof value === 'object') {
|
|
49
|
+
return Object.keys(value).sort().reduce((result, key) => {
|
|
50
|
+
result[key] = canonicalize(value[key]);
|
|
51
|
+
return result;
|
|
52
|
+
}, {} as Record<string, any>);
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isSafeColor(value: unknown): boolean {
|
|
58
|
+
if (typeof value !== 'string' || !SAFE_COLOR.test(value.trim())) return false;
|
|
59
|
+
const normalized = value.trim().toLowerCase();
|
|
60
|
+
return normalized.startsWith('#') || normalized.startsWith('rgb') || normalized.startsWith('hsl') || SAFE_COLOR_NAMES.has(normalized);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hasTemplatePlaceholder(value: unknown): boolean {
|
|
64
|
+
return typeof value === 'string' && /\{\{\s*[A-Za-z0-9_.-]+\s*\}\}/.test(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assertSafeUrl(value: unknown, allowMailto: boolean, field: string): void {
|
|
68
|
+
if (typeof value !== 'string' || !value.trim() || /[\u0000-\u0020"'<>\\]/.test(value)) {
|
|
69
|
+
throw new Error(`${field} must be a valid URL`);
|
|
70
|
+
}
|
|
71
|
+
let parsed: URL;
|
|
72
|
+
try { parsed = new URL(value); } catch { throw new Error(`${field} must be an absolute URL`); }
|
|
73
|
+
const allowed = allowMailto ? ['http:', 'https:', 'mailto:'] : ['http:', 'https:'];
|
|
74
|
+
if (!allowed.includes(parsed.protocol.toLowerCase())) throw new Error(`${field} uses an unsafe URL scheme`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* TemplateService - criação, renderização e gestão de templates de email.
|
|
79
|
+
* Cache e histórico mantêm limites finitos e retornam snapshots defensivos.
|
|
80
|
+
*/
|
|
81
|
+
export class TemplateService {
|
|
82
|
+
private templateCache = new Map<string, TemplateCache>();
|
|
83
|
+
private templateHistory = new Map<string, ITemplate[]>();
|
|
84
|
+
private cacheRequests = 0;
|
|
85
|
+
private cacheHits = 0;
|
|
86
|
+
private readonly options: Required<TemplateOptions>;
|
|
87
|
+
private cleanupTimer: ReturnType<typeof setInterval>;
|
|
88
|
+
|
|
89
|
+
constructor(options: TemplateOptions = {}) {
|
|
90
|
+
this.options = {
|
|
91
|
+
cache: true,
|
|
92
|
+
cacheTTL: 3600,
|
|
93
|
+
validateConfig: true,
|
|
94
|
+
minify: false,
|
|
95
|
+
preview: false,
|
|
96
|
+
maxCacheEntries: 100,
|
|
97
|
+
maxHistoryEntries: 100,
|
|
98
|
+
maxVersionsPerTemplate: 10,
|
|
99
|
+
...options
|
|
100
|
+
};
|
|
101
|
+
this.validateLimit(this.options.maxCacheEntries, 'maxCacheEntries');
|
|
102
|
+
this.validateLimit(this.options.maxHistoryEntries, 'maxHistoryEntries');
|
|
103
|
+
this.validateLimit(this.options.maxVersionsPerTemplate, 'maxVersionsPerTemplate');
|
|
104
|
+
this.cleanupTimer = setInterval(() => this.cleanExpiredCache(), 3600000);
|
|
105
|
+
this.cleanupTimer.unref?.();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
createTemplate(themeType: ThemeType, variant: 'light' | 'dark', config: ITemplateConfig, options?: TemplateOptions): ITemplate {
|
|
109
|
+
this.assertThemeAndVariant(themeType, variant);
|
|
110
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('Template config must be an object');
|
|
111
|
+
if (options?.validateConfig ?? this.options.validateConfig) this.validateTemplateConfig(config);
|
|
112
|
+
|
|
113
|
+
const templateId = this.generateTemplateId(themeType, variant, config);
|
|
114
|
+
const cacheEnabled = options?.cache ?? this.options.cache;
|
|
115
|
+
const ttlSeconds = options?.cacheTTL ?? this.options.cacheTTL;
|
|
116
|
+
if (!Number.isFinite(ttlSeconds) || ttlSeconds < 0) throw new Error('cacheTTL must be a finite non-negative number');
|
|
117
|
+
if (options?.maxCacheEntries !== undefined) this.validateLimit(options.maxCacheEntries, 'maxCacheEntries');
|
|
118
|
+
if (options?.maxHistoryEntries !== undefined) this.validateLimit(options.maxHistoryEntries, 'maxHistoryEntries');
|
|
119
|
+
if (options?.maxVersionsPerTemplate !== undefined) this.validateLimit(options.maxVersionsPerTemplate, 'maxVersionsPerTemplate');
|
|
120
|
+
const useCache = cacheEnabled && ttlSeconds > 0;
|
|
121
|
+
|
|
122
|
+
if (useCache) {
|
|
123
|
+
this.cacheRequests++;
|
|
124
|
+
const cached = this.templateCache.get(templateId);
|
|
125
|
+
if (cached && cached.expiresAt.getTime() > Date.now()) {
|
|
126
|
+
cached.hits++;
|
|
127
|
+
this.cacheHits++;
|
|
128
|
+
// Atualiza ordem para limitar o mapa por LRU aproximado.
|
|
129
|
+
this.templateCache.delete(templateId);
|
|
130
|
+
this.templateCache.set(templateId, cached);
|
|
131
|
+
return this.copyTemplate(cached.template);
|
|
132
|
+
}
|
|
133
|
+
if (cached) this.templateCache.delete(templateId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const safeConfig = cloneConfig(config);
|
|
137
|
+
const template = TemplateFactory.createTemplate(themeType, variant, safeConfig);
|
|
138
|
+
const enhanced = this.enhanceTemplate(template, templateId);
|
|
139
|
+
|
|
140
|
+
if (useCache) {
|
|
141
|
+
this.templateCache.set(templateId, {
|
|
142
|
+
template: enhanced,
|
|
143
|
+
createdAt: new Date(),
|
|
144
|
+
expiresAt: new Date(Date.now() + ttlSeconds * 1000),
|
|
145
|
+
hits: 0
|
|
146
|
+
});
|
|
147
|
+
this.trimCache(options?.maxCacheEntries ?? this.options.maxCacheEntries);
|
|
148
|
+
}
|
|
149
|
+
this.addToHistory(templateId, enhanced, options);
|
|
150
|
+
return this.copyTemplate(enhanced);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
cloneTemplate(template: ITemplate, modifications?: Partial<ITemplateConfig>): ITemplate {
|
|
154
|
+
const newConfig = { ...cloneConfig(template.config), ...cloneConfig(modifications || {}) };
|
|
155
|
+
return this.createTemplate(template.theme, template.variant, newConfig);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async renderTemplate(template: ITemplate, data: any, options?: { preview?: boolean }): Promise<string> {
|
|
159
|
+
try {
|
|
160
|
+
// _meta é reservado e sempre produzido internamente, nunca aceito do chamador.
|
|
161
|
+
const renderData = {
|
|
162
|
+
...(data && typeof data === 'object' ? data : {}),
|
|
163
|
+
_meta: {
|
|
164
|
+
isPreview: options?.preview ?? this.options.preview,
|
|
165
|
+
renderDate: new Date().toISOString(),
|
|
166
|
+
templateName: template.name,
|
|
167
|
+
theme: template.theme,
|
|
168
|
+
variant: template.variant
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const html = await template.render(renderData);
|
|
172
|
+
// Minificação global removida: colapsar whitespace corrompia texto pré-formatado e <code>.
|
|
173
|
+
return this.options.minify ? this.minifyHtml(html) : html;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
throw new Error(`Failed to render template: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async previewTemplate(themeType: ThemeType, variant: 'light' | 'dark', config: ITemplateConfig, data: any): Promise<string> {
|
|
180
|
+
const template = this.createTemplate(themeType, variant, config, { cache: false });
|
|
181
|
+
return this.renderTemplate(template, data, { preview: true });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
getThemeInfo(themeType: ThemeType): { name: string; description: string; features: string[]; availableVariants: ('light' | 'dark')[]; defaultConfig: Partial<ITemplateConfig> } {
|
|
185
|
+
const info = TemplateFactory.getThemeInfo(themeType);
|
|
186
|
+
const theme = TemplateFactory.getTheme(themeType);
|
|
187
|
+
if (!info || !theme) throw new Error(`Theme ${themeType} not found`);
|
|
188
|
+
return { ...info, availableVariants: ['light', 'dark'], defaultConfig: this.getDefaultConfigForTheme(themeType) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
listThemes(): ThemeType[] { return TemplateFactory.listThemes(); }
|
|
192
|
+
|
|
193
|
+
getTemplateStats(): { totalTemplates: number; cachedTemplates: number; totalHits: number; cacheRequests: number; templatesByTheme: Record<ThemeType, number>; cacheHitRate: number } {
|
|
194
|
+
const templatesByTheme = Object.fromEntries(
|
|
195
|
+
Object.values(ThemeType).map((theme) => [theme, 0])
|
|
196
|
+
) as Record<ThemeType, number>;
|
|
197
|
+
for (const cache of this.templateCache.values()) {
|
|
198
|
+
templatesByTheme[cache.template.theme]++;
|
|
199
|
+
}
|
|
200
|
+
const totalCached = this.templateCache.size;
|
|
201
|
+
return {
|
|
202
|
+
totalTemplates: this.templateHistory.size,
|
|
203
|
+
cachedTemplates: totalCached,
|
|
204
|
+
totalHits: this.cacheHits,
|
|
205
|
+
cacheRequests: this.cacheRequests,
|
|
206
|
+
templatesByTheme,
|
|
207
|
+
cacheHitRate: this.cacheRequests > 0 ? (this.cacheHits / this.cacheRequests) * 100 : 0
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
clearCache(themeType?: ThemeType): number {
|
|
212
|
+
let cleared = 0;
|
|
213
|
+
for (const [id, cache] of this.templateCache.entries()) {
|
|
214
|
+
if (!themeType || cache.template.theme === themeType) {
|
|
215
|
+
this.templateCache.delete(id);
|
|
216
|
+
cleared++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return cleared;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
getTemplateHistory(templateId: string): ITemplate[] {
|
|
223
|
+
return (this.templateHistory.get(templateId) || []).map(template => this.copyTemplate(template));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
restoreTemplateVersion(templateId: string, versionIndex: number): ITemplate | null {
|
|
227
|
+
const history = this.templateHistory.get(templateId);
|
|
228
|
+
if (!history || !Number.isInteger(versionIndex) || versionIndex < 0 || versionIndex >= history.length) return null;
|
|
229
|
+
const oldTemplate = history[versionIndex];
|
|
230
|
+
return this.createTemplate(oldTemplate.theme, oldTemplate.variant, oldTemplate.config, { cache: false });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Encerrar o timer de limpeza quando a instância não for mais necessária. */
|
|
234
|
+
dispose(): void { clearInterval(this.cleanupTimer); }
|
|
235
|
+
|
|
236
|
+
private validateTemplateConfig(config: ITemplateConfig): void {
|
|
237
|
+
const errors: string[] = [];
|
|
238
|
+
const fail = (fn: () => void) => { try { fn(); } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); } };
|
|
239
|
+
const enumValue = (value: any, allowed: string[], label: string) => {
|
|
240
|
+
if (value !== undefined && !allowed.includes(value)) errors.push(`Invalid ${label}: ${value}`);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
if (config.language !== undefined && (typeof config.language !== 'string' || config.language.length > 63 || !SAFE_LANGUAGE_TAG.test(config.language))) {
|
|
244
|
+
errors.push('Invalid template language tag');
|
|
245
|
+
}
|
|
246
|
+
enumValue(config.direction, ['ltr', 'rtl', 'auto'], 'template text direction');
|
|
247
|
+
enumValue(config.linkDecoration, ['none', 'underline'], 'template linkDecoration');
|
|
248
|
+
if (config.preheader !== undefined && (typeof config.preheader !== 'string' || config.preheader.length > 500 || /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(config.preheader))) {
|
|
249
|
+
errors.push('preheader must be a string of at most 500 printable characters');
|
|
250
|
+
}
|
|
251
|
+
enumValue(config.layout, ['full', 'minimal'], 'layout');
|
|
252
|
+
enumValue(config.spacing, ['compact', 'normal', 'relaxed'], 'spacing');
|
|
253
|
+
enumValue(config.borderRadius, ['none', 'small', 'medium', 'large'], 'borderRadius');
|
|
254
|
+
|
|
255
|
+
if (config.header) {
|
|
256
|
+
if (typeof config.header.show !== 'boolean') errors.push('header.show must be a boolean');
|
|
257
|
+
if (config.header.logo) {
|
|
258
|
+
enumValue(config.header.logo.type, ['text', 'image'], 'header.logo.type');
|
|
259
|
+
enumValue(config.header.logo.size, ['small', 'medium', 'large'], 'header.logo.size');
|
|
260
|
+
if (config.header.logo.type === 'image' && !hasTemplatePlaceholder(config.header.logo.imageUrl)) fail(() => assertSafeUrl(config.header!.logo!.imageUrl, false, 'header.logo.imageUrl'));
|
|
261
|
+
if (config.header.logo.type === 'text' && typeof config.header.logo.text !== 'string') errors.push('header.logo.text must be a string');
|
|
262
|
+
}
|
|
263
|
+
if (config.header.backgroundColor !== undefined && !isSafeColor(config.header.backgroundColor)) errors.push('Invalid header.backgroundColor');
|
|
264
|
+
if (config.header.textColor !== undefined && !isSafeColor(config.header.textColor)) errors.push('Invalid header.textColor');
|
|
265
|
+
}
|
|
266
|
+
const body = config.body;
|
|
267
|
+
if (body) {
|
|
268
|
+
enumValue(body.alignment, ['left', 'center', 'right'], 'body.alignment');
|
|
269
|
+
enumValue(body.buttonVariant, ['primary', 'secondary', 'success', 'danger'], 'body.buttonVariant');
|
|
270
|
+
if (body.fontSize !== undefined && (!Number.isFinite(body.fontSize) || body.fontSize < 8 || body.fontSize > 72)) errors.push('body.fontSize must be between 8 and 72');
|
|
271
|
+
for (const key of ['backgroundColor', 'textColor'] as const) {
|
|
272
|
+
const color = body[key];
|
|
273
|
+
if (color !== undefined && !isSafeColor(color)) errors.push(`Invalid body.${key}`);
|
|
274
|
+
}
|
|
275
|
+
const buttonUrl = body.buttonUrl;
|
|
276
|
+
if (buttonUrl !== undefined && !hasTemplatePlaceholder(buttonUrl)) fail(() => assertSafeUrl(buttonUrl, true, 'body.buttonUrl'));
|
|
277
|
+
for (const key of ['title', 'message', 'content', 'buttonText'] as const) {
|
|
278
|
+
if (body[key] !== undefined && typeof body[key] !== 'string') errors.push(`body.${key} must be a string`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const footer = config.footer;
|
|
282
|
+
if (footer) {
|
|
283
|
+
if (typeof footer.show !== 'boolean') errors.push('footer.show must be a boolean');
|
|
284
|
+
for (const key of ['backgroundColor', 'textColor'] as const) {
|
|
285
|
+
const color = footer[key];
|
|
286
|
+
if (color !== undefined && !isSafeColor(color)) errors.push(`Invalid footer.${key}`);
|
|
287
|
+
}
|
|
288
|
+
for (const link of footer.links || []) {
|
|
289
|
+
if (!link || typeof link.text !== 'string') errors.push('Footer links require text and URL');
|
|
290
|
+
else if (!hasTemplatePlaceholder(link.url)) fail(() => assertSafeUrl(link.url, true, 'footer link URL'));
|
|
291
|
+
}
|
|
292
|
+
for (const link of footer.socialLinks || []) {
|
|
293
|
+
if (!link || !SOCIAL_PLATFORMS.has(link.platform)) errors.push(`Invalid social platform: ${link?.platform}`);
|
|
294
|
+
else if (!hasTemplatePlaceholder(link.url)) fail(() => assertSafeUrl(link.url, false, 'social link URL'));
|
|
295
|
+
}
|
|
296
|
+
const unsubscribeUrl = footer.unsubscribeUrl;
|
|
297
|
+
if (unsubscribeUrl !== undefined && !hasTemplatePlaceholder(unsubscribeUrl)) fail(() => assertSafeUrl(unsubscribeUrl, true, 'footer.unsubscribeUrl'));
|
|
298
|
+
}
|
|
299
|
+
if (errors.length) throw new Error(`Template configuration validation failed:\n${errors.join('\n')}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private assertThemeAndVariant(themeType: ThemeType, variant: string): void {
|
|
303
|
+
if (!VALID_THEMES.includes(themeType)) throw new Error(`Invalid theme: ${themeType}`);
|
|
304
|
+
if (variant !== 'light' && variant !== 'dark') throw new Error(`Invalid template variant: ${variant}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private generateTemplateId(themeType: ThemeType, variant: 'light' | 'dark', config: ITemplateConfig): string {
|
|
308
|
+
const canonical = JSON.stringify(canonicalize({ theme: themeType, variant, config }));
|
|
309
|
+
return `${themeType}_${variant}_${createHash('sha256').update(canonical).digest('hex')}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private enhanceTemplate(template: ITemplate, templateId: string): ITemplate {
|
|
313
|
+
const enhanced = { ...template } as ITemplate & { getVersion?: () => number; getId?: () => string; clone?: (modifications?: Partial<ITemplateConfig>) => ITemplate };
|
|
314
|
+
enhanced.getVersion = () => this.templateHistory.get(templateId)?.length || 1;
|
|
315
|
+
enhanced.getId = () => templateId;
|
|
316
|
+
enhanced.clone = (modifications?: Partial<ITemplateConfig>) => this.cloneTemplate(enhanced, modifications);
|
|
317
|
+
return enhanced;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private copyTemplate(template: ITemplate): ITemplate {
|
|
321
|
+
return { ...template, config: cloneConfig(template.config) };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private addToHistory(templateId: string, template: ITemplate, options?: TemplateOptions): void {
|
|
325
|
+
const history = this.templateHistory.get(templateId) || [];
|
|
326
|
+
history.push(template);
|
|
327
|
+
const perTemplateLimit = options?.maxVersionsPerTemplate ?? this.options.maxVersionsPerTemplate;
|
|
328
|
+
while (history.length > perTemplateLimit) history.shift();
|
|
329
|
+
this.templateHistory.delete(templateId);
|
|
330
|
+
this.templateHistory.set(templateId, history);
|
|
331
|
+
const historyLimit = options?.maxHistoryEntries ?? this.options.maxHistoryEntries;
|
|
332
|
+
while (this.templateHistory.size > historyLimit) {
|
|
333
|
+
const oldest = this.templateHistory.keys().next().value;
|
|
334
|
+
if (oldest === undefined) break;
|
|
335
|
+
this.templateHistory.delete(oldest);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private trimCache(limit: number): void {
|
|
340
|
+
while (this.templateCache.size > limit) {
|
|
341
|
+
const oldest = this.templateCache.keys().next().value;
|
|
342
|
+
if (oldest === undefined) break;
|
|
343
|
+
this.templateCache.delete(oldest);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private cleanExpiredCache(): void {
|
|
348
|
+
const now = Date.now();
|
|
349
|
+
for (const [id, cache] of this.templateCache.entries()) {
|
|
350
|
+
if (cache.expiresAt.getTime() <= now) this.templateCache.delete(id);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Mantido como transformação deliberadamente não destrutiva. HTML minification baseada em regex
|
|
355
|
+
// alteraria espaços de texto e conteúdo de <code>/<pre>; minify=true preserva o HTML original.
|
|
356
|
+
private minifyHtml(html: string): string { return html; }
|
|
357
|
+
|
|
358
|
+
private validateLimit(value: number, name: string): void {
|
|
359
|
+
if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private getDefaultConfigForTheme(themeType: ThemeType): Partial<ITemplateConfig> {
|
|
363
|
+
const baseConfig: Partial<ITemplateConfig> = {
|
|
364
|
+
header: { show: true, logo: { type: 'text', text: 'MyApp', size: 'medium' } },
|
|
365
|
+
footer: { show: true, copyrightText: `© ${new Date().getFullYear()} MyApp. All rights reserved.` },
|
|
366
|
+
layout: 'full', spacing: 'normal', borderRadius: 'medium'
|
|
367
|
+
};
|
|
368
|
+
switch (themeType) {
|
|
369
|
+
case ThemeType.MONOKAI: return { ...baseConfig, borderRadius: 'small' };
|
|
370
|
+
case ThemeType.MODERN: return { ...baseConfig, borderRadius: 'large', spacing: 'relaxed' };
|
|
371
|
+
case ThemeType.CORPORATE: return { ...baseConfig, borderRadius: 'small' };
|
|
372
|
+
case ThemeType.MINIMAL: return { ...baseConfig, borderRadius: 'none', spacing: 'relaxed' };
|
|
373
|
+
default: return baseConfig;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async preloadTemplates(templates: Array<{ themeType: ThemeType; variant: 'light' | 'dark'; config: ITemplateConfig }>): Promise<void> {
|
|
378
|
+
await Promise.all(templates.map(({ themeType, variant, config }) => this.createTemplate(themeType, variant, config)));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
exportTemplate(template: ITemplate): string {
|
|
382
|
+
return JSON.stringify({ name: template.name, theme: template.theme, variant: template.variant, config: template.config, exportedAt: new Date().toISOString(), version: (template as any).getVersion?.() || 1 }, null, 2);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
importTemplate(jsonData: string): ITemplate {
|
|
386
|
+
try {
|
|
387
|
+
const data = JSON.parse(jsonData);
|
|
388
|
+
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)) {
|
|
389
|
+
throw new Error('Invalid template data: theme, variant, and config are required and must be valid');
|
|
390
|
+
}
|
|
391
|
+
return this.createTemplate(data.theme, data.variant, data.config);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
throw new Error(`Failed to import template: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
generateExampleTemplate(themeType: ThemeType = ThemeType.MODERN, variant: 'light' | 'dark' = 'light'): ITemplate {
|
|
398
|
+
const exampleConfig: ITemplateConfig = {
|
|
399
|
+
header: { show: true, logo: { type: 'text', text: 'ExampleApp', size: 'medium' }, backgroundColor: variant === 'light' ? '#ffffff' : '#1a1a1a', textColor: variant === 'light' ? '#111827' : '#f9fafb' },
|
|
400
|
+
footer: {
|
|
401
|
+
show: true,
|
|
402
|
+
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' } ],
|
|
403
|
+
socialLinks: [ { platform: 'twitter', url: 'https://twitter.com/example' }, { platform: 'github', url: 'https://github.com/example' }, { platform: 'linkedin', url: 'https://linkedin.com/company/example' } ],
|
|
404
|
+
copyrightText: `© ${new Date().getFullYear()} ExampleApp. All rights reserved.`, unsubscribeText: 'Unsubscribe',
|
|
405
|
+
backgroundColor: variant === 'light' ? '#f9fafb' : '#111827', textColor: variant === 'light' ? '#6b7280' : '#9ca3af'
|
|
406
|
+
},
|
|
407
|
+
layout: 'full', spacing: 'normal', borderRadius: themeType === ThemeType.MINIMAL ? 'none' : 'medium'
|
|
408
|
+
};
|
|
409
|
+
return this.createTemplate(themeType, variant, exampleConfig);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
isValidTemplate(template: any): boolean {
|
|
413
|
+
try {
|
|
414
|
+
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') return false;
|
|
415
|
+
this.validateTemplateConfig(template.config);
|
|
416
|
+
return true;
|
|
417
|
+
} catch { return false; }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
getTemplateDetails(templateId: string): { template: ITemplate | null; history: ITemplate[]; cacheInfo: TemplateCache | null; usageCount: number } {
|
|
421
|
+
const cacheInfo = this.templateCache.get(templateId) || null;
|
|
422
|
+
const history = (this.templateHistory.get(templateId) || []).map(template => this.copyTemplate(template));
|
|
423
|
+
const template = cacheInfo?.template || history[history.length - 1] || null;
|
|
424
|
+
return {
|
|
425
|
+
template: template ? this.copyTemplate(template) : null,
|
|
426
|
+
history,
|
|
427
|
+
cacheInfo: cacheInfo ? { ...cacheInfo, template: this.copyTemplate(cacheInfo.template), createdAt: new Date(cacheInfo.createdAt), expiresAt: new Date(cacheInfo.expiresAt) } : null,
|
|
428
|
+
usageCount: cacheInfo?.hits || 0
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
}
|