@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,31 @@
|
|
|
1
|
+
function relativeLuminance(color: string): number | undefined {
|
|
2
|
+
const hex = color.replace(/^#/, '');
|
|
3
|
+
const normalized = hex.length === 3 ? hex.split('').map(channel => channel + channel).join('') : hex;
|
|
4
|
+
if (!/^[\da-f]{6}$/i.test(normalized)) return undefined;
|
|
5
|
+
const channels = [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16) / 255);
|
|
6
|
+
return channels.map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)
|
|
7
|
+
.reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function contrastRatio(foreground: string, background: string): number {
|
|
11
|
+
const foregroundLuminance = relativeLuminance(foreground);
|
|
12
|
+
const backgroundLuminance = relativeLuminance(background);
|
|
13
|
+
if (foregroundLuminance === undefined || backgroundLuminance === undefined) return 0;
|
|
14
|
+
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
|
|
15
|
+
const darker = Math.min(foregroundLuminance, backgroundLuminance);
|
|
16
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function accessibleTextColor(background: string): '#000000' | '#ffffff' {
|
|
20
|
+
const luminance = relativeLuminance(background);
|
|
21
|
+
if (luminance === undefined) return '#ffffff';
|
|
22
|
+
const whiteContrast = 1.05 / (luminance + 0.05);
|
|
23
|
+
const blackContrast = (luminance + 0.05) / 0.05;
|
|
24
|
+
return whiteContrast >= blackContrast ? '#ffffff' : '#000000';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readableTextColor(preferred: string, background: string, fallback?: string): string {
|
|
28
|
+
if (contrastRatio(preferred, background) >= 4.5) return preferred;
|
|
29
|
+
if (fallback && contrastRatio(fallback, background) >= 4.5) return fallback;
|
|
30
|
+
return accessibleTextColor(background);
|
|
31
|
+
}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
|
|
2
|
+
import { ITemplateConfig } from '../../core/interfaces/template.interface';
|
|
3
|
+
import { ITheme, IThemeColorToken, IThemeColors, IThemeEmailStyles } from '../themes/theme.interface';
|
|
4
|
+
import { MonokaiTheme } from '../themes/monokai.theme';
|
|
5
|
+
import { accessibleTextColor, contrastRatio, readableTextColor } from './color-accessibility';
|
|
6
|
+
|
|
7
|
+
function escapeHtml(value: unknown): string {
|
|
8
|
+
return String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character]!));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function safeHref(value: string, allowMailto = false): string {
|
|
12
|
+
if (typeof value !== 'string' || !value.trim() || /[\u0000-\u0020"'<>\\]/.test(value)) throw new Error('Unsafe URL in template');
|
|
13
|
+
let url: URL;
|
|
14
|
+
try { url = new URL(value); } catch { throw new Error('Template URLs must be absolute'); }
|
|
15
|
+
const schemes = allowMailto ? ['http:', 'https:', 'mailto:'] : ['http:', 'https:'];
|
|
16
|
+
if (!schemes.includes(url.protocol.toLowerCase())) throw new Error('Unsafe URL scheme in template');
|
|
17
|
+
return escapeHtml(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
const DEFAULT_EMAIL_STYLES: IThemeEmailStyles = {
|
|
22
|
+
maxWidthPx: 600,
|
|
23
|
+
container: { borderColor: 'border', borderWidth: '1px', borderRadius: '8px', boxShadow: 'none' },
|
|
24
|
+
header: { accentColor: 'border', accentWidth: '1px', horizontalPadding: '24px', logoColor: 'text', imageBorderRadius: '0px' },
|
|
25
|
+
body: { accentColor: 'none', accentWidth: '0px', borderRadius: '0px', boxShadow: 'none', horizontalPadding: '24px', lineHeight: 1.6 },
|
|
26
|
+
button: { appearance: 'solid', borderRadius: '6px', borderWidth: '0px', boxShadow: 'none' },
|
|
27
|
+
footer: { accentColor: 'border', accentWidth: '1px' }
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function colorFor(token: IThemeColorToken, colors: IThemeColors): string | undefined {
|
|
31
|
+
return token === 'none' ? undefined : colors[token];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function borderFor(token: IThemeColorToken, width: string, colors: IThemeColors): string {
|
|
35
|
+
const color = colorFor(token, colors);
|
|
36
|
+
return !color || !width || Number.parseFloat(width) <= 0 ? 'none' : `${width} solid ${color}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class TemplateBuilder {
|
|
40
|
+
private template: string = '';
|
|
41
|
+
private theme: ITheme;
|
|
42
|
+
private config: ITemplateConfig;
|
|
43
|
+
private variant: 'light' | 'dark';
|
|
44
|
+
|
|
45
|
+
constructor(theme: ITheme, config: ITemplateConfig, variant: 'light' | 'dark') {
|
|
46
|
+
this.theme = theme;
|
|
47
|
+
this.config = config;
|
|
48
|
+
this.variant = variant;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private get emailStyles(): IThemeEmailStyles {
|
|
52
|
+
return this.theme.emailStyles ?? DEFAULT_EMAIL_STYLES;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
buildPreheader(): this {
|
|
56
|
+
const preheader = this.config.preheader;
|
|
57
|
+
if (!preheader) return this;
|
|
58
|
+
this.template += `<div aria-hidden="true" style="display:none!important;visibility:hidden;opacity:0;color:transparent;height:0;width:0;max-height:0;max-width:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;">${escapeHtml(preheader)}</div>`;
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
buildHeader(content?: string): this {
|
|
63
|
+
if (!this.config.header?.show) return this;
|
|
64
|
+
|
|
65
|
+
const colors = this.variant === 'light' ? this.theme.light : this.theme.dark;
|
|
66
|
+
const styles = this.emailStyles.header;
|
|
67
|
+
const headerBorder = borderFor(styles.accentColor, styles.accentWidth, colors);
|
|
68
|
+
const headerBackground = this.config.header?.backgroundColor || colors.background;
|
|
69
|
+
const headerTextColor = readableTextColor(this.config.header?.textColor || colors.text, headerBackground, colors.text);
|
|
70
|
+
const headerStyles = `
|
|
71
|
+
background-color: ${headerBackground};
|
|
72
|
+
color: ${headerTextColor};
|
|
73
|
+
padding: ${this.theme.spacing.lg} ${styles.horizontalPadding};
|
|
74
|
+
border-bottom: ${headerBorder};
|
|
75
|
+
font-family: ${this.theme.typography.fontFamily};
|
|
76
|
+
`;
|
|
77
|
+
const logoHtml = this.buildLogo();
|
|
78
|
+
|
|
79
|
+
this.template += `
|
|
80
|
+
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="${headerBackground}" style="width:100%;background-color:${headerBackground};">
|
|
81
|
+
<tr>
|
|
82
|
+
<td class="email-header-cell" style="${escapeHtml(headerStyles)}" align="center">
|
|
83
|
+
${logoHtml}
|
|
84
|
+
${content ? `
|
|
85
|
+
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
|
86
|
+
<tr><td style="padding-top:${this.theme.spacing.md};font-size:${this.theme.typography.fontSizes.medium};line-height:1.4;">
|
|
87
|
+
${escapeHtml(content)}
|
|
88
|
+
</td></tr>
|
|
89
|
+
</table>
|
|
90
|
+
` : ''}
|
|
91
|
+
</td>
|
|
92
|
+
</tr>
|
|
93
|
+
</table>
|
|
94
|
+
`;
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private buildLogo(): string {
|
|
99
|
+
const logo = this.config.header?.logo;
|
|
100
|
+
if (!logo) return '';
|
|
101
|
+
|
|
102
|
+
const colors = this.variant === 'light' ? this.theme.light : this.theme.dark;
|
|
103
|
+
const styles = this.emailStyles.header;
|
|
104
|
+
if (logo.type === 'text') {
|
|
105
|
+
const logoBackground = this.config.header?.backgroundColor || colors.background;
|
|
106
|
+
const preferredColor = this.config.header?.textColor || colors[styles.logoColor];
|
|
107
|
+
const logoColor = readableTextColor(preferredColor, logoBackground, colors.text);
|
|
108
|
+
const textStyles = `
|
|
109
|
+
display: inline-block;
|
|
110
|
+
max-width: 100%;
|
|
111
|
+
font-family: ${styles.logoFontFamily || this.theme.typography.fontFamily};
|
|
112
|
+
font-size: ${this.theme.typography.fontSizes.xlarge};
|
|
113
|
+
font-weight: ${this.theme.typography.fontWeights.bold};
|
|
114
|
+
line-height: 1.2;
|
|
115
|
+
color: ${logoColor};
|
|
116
|
+
text-transform: ${styles.logoTextTransform || 'none'};
|
|
117
|
+
letter-spacing: ${styles.logoLetterSpacing || 'normal'};
|
|
118
|
+
`;
|
|
119
|
+
return `<div style="${escapeHtml(textStyles)}">${escapeHtml(logo.text || 'Logo')}</div>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (logo.type === 'image') {
|
|
123
|
+
const imageHeight = logo.size === 'small' ? 30 : logo.size === 'large' ? 100 : 60;
|
|
124
|
+
return `<img src="${safeHref(logo.imageUrl || '')}" alt="${escapeHtml(logo.alt || 'Logo')}" height="${imageHeight}" style="display:block;width:auto;height:${imageHeight}px;max-width:100%;border:0;border-radius:${styles.imageBorderRadius};margin:0 auto;" />`;
|
|
125
|
+
}
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
buildBody(content: string): this {
|
|
130
|
+
const colors = this.variant === 'light' ? this.theme.light : this.theme.dark;
|
|
131
|
+
const styles = this.emailStyles.body;
|
|
132
|
+
const bodyBackground = this.config.body?.backgroundColor || colors.background;
|
|
133
|
+
const bodyTextColor = readableTextColor(this.config.body?.textColor || colors.text, bodyBackground, colors.text);
|
|
134
|
+
const accentBorder = borderFor(styles.accentColor, styles.accentWidth, colors);
|
|
135
|
+
let processedContent = content;
|
|
136
|
+
if (this.theme.id === 'corporate') processedContent = this.formatCorporateContent(content, bodyBackground, bodyTextColor);
|
|
137
|
+
else if (this.theme.id === 'minimal') processedContent = this.formatMinimalContent(content, bodyTextColor);
|
|
138
|
+
else if (this.theme.id === 'monokai' && content.includes('<code>')) processedContent = this.highlightCode(content, bodyBackground, bodyTextColor);
|
|
139
|
+
|
|
140
|
+
const bodyStyles = `
|
|
141
|
+
padding: ${this.theme.spacing.xl} ${styles.horizontalPadding};
|
|
142
|
+
background-color: ${bodyBackground};
|
|
143
|
+
color: ${bodyTextColor};
|
|
144
|
+
font-family: ${this.theme.typography.fontFamily};
|
|
145
|
+
font-size: ${this.theme.typography.fontSizes.medium};
|
|
146
|
+
line-height: ${styles.lineHeight};
|
|
147
|
+
border-left: ${accentBorder};
|
|
148
|
+
border-radius: ${styles.borderRadius};
|
|
149
|
+
box-shadow: ${styles.boxShadow};
|
|
150
|
+
word-wrap: break-word;
|
|
151
|
+
overflow-wrap: break-word;
|
|
152
|
+
`;
|
|
153
|
+
this.template += `
|
|
154
|
+
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="${bodyBackground}" style="width:100%;background-color:${bodyBackground};">
|
|
155
|
+
<tr><td class="email-body" style="${escapeHtml(bodyStyles)}">${processedContent}</td></tr>
|
|
156
|
+
</table>
|
|
157
|
+
`;
|
|
158
|
+
return this;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private formatCorporateContent(content: string, background: string, textColor: string): string {
|
|
162
|
+
const colors = this.variant === 'light' ? this.theme.light : this.theme.dark;
|
|
163
|
+
const quoteColor = contrastRatio(colors.textMuted, background) >= 4.5 ? colors.textMuted : textColor;
|
|
164
|
+
return content
|
|
165
|
+
.replace(/<blockquote(?:\s[^>]*)?>([\s\S]*?)<\/blockquote>/gi, (_match, quote) => `<blockquote style="border-left:3px solid ${colors.primary};margin:${this.theme.spacing.md} 0;padding:${this.theme.spacing.md};font-style:italic;color:${quoteColor};">${quote}</blockquote>`)
|
|
166
|
+
.replace(/<highlight(?:\s[^>]*)?>([\s\S]*?)<\/highlight>/gi, (_match, text) => `<span style="background-color:${colors.border};padding:0 ${this.theme.spacing.xs};font-weight:bold;color:${readableTextColor(colors.text, colors.border, textColor)};">${text}</span>`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private formatMinimalContent(content: string, textColor: string): string {
|
|
170
|
+
const headingSizes: Record<string, string> = { '1': '24px', '2': '20px', '3': '18px', '4': '16px', '5': '14px', '6': '13px' };
|
|
171
|
+
return content
|
|
172
|
+
.replace(/<h([1-6])(?:\s[^>]*)?>/gi, (_match, level: string) => `<h${level} style="font-size:${headingSizes[level]};font-weight:${this.theme.typography.fontWeights.medium};margin:${this.theme.spacing.lg} 0 ${this.theme.spacing.md};line-height:1.3;color:${textColor};">`)
|
|
173
|
+
.replace(/<p(?:\s[^>]*)?>/gi, `<p style="margin:0 0 ${this.theme.spacing.md};">`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private highlightCode(content: string, background: string, textColor: string): string {
|
|
177
|
+
const colors = this.variant === 'light' ? this.theme.light : this.theme.dark;
|
|
178
|
+
const monokai = this.theme as MonokaiTheme;
|
|
179
|
+
const radius = this.emailStyles.body.borderRadius;
|
|
180
|
+
const codeStyles = `margin:${this.theme.spacing.md} 0;background-color:${background};color:${textColor};padding:${this.theme.spacing.md};border:1px solid ${colors.border};border-radius:${radius};font-family:${this.theme.typography.fontFamily};font-size:13px;line-height:1.5;white-space:pre-wrap;word-wrap:break-word;`;
|
|
181
|
+
return content.replace(/<code(?:\s[^>]*)?>([\s\S]*?)<\/code>/gi, (_match, code) => `<pre style="${escapeHtml(codeStyles)}">${this.applySyntaxHighlighting(code, monokai, background, textColor)}</pre>`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private applySyntaxHighlighting(code: string, monokai: MonokaiTheme, background: string, textColor: string): string {
|
|
185
|
+
const keywords = new Set(['function', 'const', 'let', 'var', 'if', 'else', 'return', 'import', 'from']);
|
|
186
|
+
const tokenPattern = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\b(?:function|const|let|var|if|else|return|import|from)\b|\b\d+\b|\b[A-Za-z_$][\w$]*(?=\s*\()/g;
|
|
187
|
+
let result = '';
|
|
188
|
+
let lastIndex = 0;
|
|
189
|
+
for (const match of code.matchAll(tokenPattern)) {
|
|
190
|
+
const token = match[0];
|
|
191
|
+
const index = match.index ?? 0;
|
|
192
|
+
result += escapeHtml(code.slice(lastIndex, index));
|
|
193
|
+
const preferredColor = token.startsWith('"') || token.startsWith("'") ? monokai.codeHighlight.string
|
|
194
|
+
: /^\d+$/.test(token) ? monokai.codeHighlight.number
|
|
195
|
+
: keywords.has(token) ? monokai.codeHighlight.keyword : monokai.codeHighlight.function;
|
|
196
|
+
const color = contrastRatio(preferredColor, background) >= 4.5 ? preferredColor : textColor;
|
|
197
|
+
result += `<span style="color:${color};">${escapeHtml(token)}</span>`;
|
|
198
|
+
lastIndex = index + token.length;
|
|
199
|
+
}
|
|
200
|
+
return result + escapeHtml(code.slice(lastIndex));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
buildButton(text: string, url: string, variant: 'primary' | 'secondary' | 'success' | 'danger' = 'primary'): this {
|
|
204
|
+
const colors = this.variant === 'light' ? this.theme.light : this.theme.dark;
|
|
205
|
+
if (!['primary', 'secondary', 'success', 'danger'].includes(variant)) throw new Error('Invalid CTA variant');
|
|
206
|
+
const safeUrl = safeHref(url, true);
|
|
207
|
+
const buttonColor = variant === 'primary' ? colors.primary : variant === 'secondary' ? colors.secondary : variant === 'success' ? colors.success : colors.error;
|
|
208
|
+
const button = this.emailStyles.button;
|
|
209
|
+
const isOutline = button.appearance === 'outline';
|
|
210
|
+
const alignment = this.config.body?.alignment || 'left';
|
|
211
|
+
const buttonTextColor = isOutline
|
|
212
|
+
? contrastRatio(buttonColor, colors.background) >= 4.5 ? buttonColor : colors.text
|
|
213
|
+
: accessibleTextColor(buttonColor);
|
|
214
|
+
const buttonStyles = `
|
|
215
|
+
display: inline-block;
|
|
216
|
+
box-sizing: border-box;
|
|
217
|
+
max-width: 100%;
|
|
218
|
+
padding: 13px 24px;
|
|
219
|
+
background-color: ${isOutline ? colors.background : buttonColor};
|
|
220
|
+
border: ${button.borderWidth} solid ${isOutline ? buttonColor : 'transparent'};
|
|
221
|
+
border-radius: ${button.borderRadius};
|
|
222
|
+
box-shadow: ${button.boxShadow};
|
|
223
|
+
color: ${buttonTextColor};
|
|
224
|
+
font-family: ${this.theme.typography.fontFamily};
|
|
225
|
+
font-size: ${this.theme.typography.fontSizes.medium};
|
|
226
|
+
font-weight: ${this.theme.typography.fontWeights.bold};
|
|
227
|
+
line-height: 1.3;
|
|
228
|
+
letter-spacing: ${button.letterSpacing || 'normal'};
|
|
229
|
+
text-transform: ${button.textTransform || 'none'};
|
|
230
|
+
text-decoration: none !important;
|
|
231
|
+
`;
|
|
232
|
+
this.template += `
|
|
233
|
+
<table width="100%" class="button-wrap" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
234
|
+
<tr><td align="${alignment}" style="padding:${this.theme.spacing.sm} 8px;">
|
|
235
|
+
<table class="button" align="${alignment}" cellpadding="0" cellspacing="0" role="presentation">
|
|
236
|
+
<tr><td align="${alignment}"><a class="button-link" href="${safeUrl}" style="${escapeHtml(buttonStyles)}">${escapeHtml(text)}</a></td></tr>
|
|
237
|
+
</table>
|
|
238
|
+
</td></tr>
|
|
239
|
+
</table>
|
|
240
|
+
`;
|
|
241
|
+
return this;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
buildFooter(): this {
|
|
245
|
+
if (!this.config.footer?.show) return this;
|
|
246
|
+
const colors = this.variant === 'light' ? this.theme.light : this.theme.dark;
|
|
247
|
+
const styles = this.emailStyles.footer;
|
|
248
|
+
const footerBorder = borderFor(styles.accentColor, styles.accentWidth, colors);
|
|
249
|
+
const footerBackground = this.config.footer?.backgroundColor || colors.background;
|
|
250
|
+
const defaultFooterTextColor = contrastRatio(colors.textMuted, footerBackground) >= 4.5 ? colors.textMuted : colors.text;
|
|
251
|
+
const footerTextColor = readableTextColor(this.config.footer?.textColor || defaultFooterTextColor, footerBackground, colors.text);
|
|
252
|
+
const copyrightStyles = escapeHtml(`padding-top:${this.theme.spacing.md};font-family:${this.theme.typography.fontFamily};font-size:${this.theme.typography.fontSizes.small};line-height:1.5;color:${footerTextColor};text-align:center;`);
|
|
253
|
+
const unsubscribeCellStyles = escapeHtml(`padding-top:${this.theme.spacing.sm};text-align:center;font-family:${this.theme.typography.fontFamily};font-size:${this.theme.typography.fontSizes.small};line-height:1.5;`);
|
|
254
|
+
const footerStyles = `
|
|
255
|
+
background-color: ${footerBackground};
|
|
256
|
+
border-top: ${footerBorder};
|
|
257
|
+
padding: ${this.theme.spacing.lg} ${this.theme.spacing.xl};
|
|
258
|
+
text-align: center;
|
|
259
|
+
font-family: ${this.theme.typography.fontFamily};
|
|
260
|
+
font-size: ${this.theme.typography.fontSizes.small};
|
|
261
|
+
line-height: 1.5;
|
|
262
|
+
color: ${footerTextColor};
|
|
263
|
+
`;
|
|
264
|
+
const footerLinks = this.buildFooterLinks();
|
|
265
|
+
const socialLinks = this.buildSocialLinks();
|
|
266
|
+
this.template += `
|
|
267
|
+
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="${footerBackground}" style="width:100%;background-color:${footerBackground};">
|
|
268
|
+
<tr><td class="email-footer-cell" bgcolor="${footerBackground}" style="${escapeHtml(footerStyles)}" align="center">
|
|
269
|
+
${footerLinks}
|
|
270
|
+
${socialLinks}
|
|
271
|
+
${this.config.footer?.copyrightText ? `
|
|
272
|
+
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
|
273
|
+
<tr><td style="${copyrightStyles}">
|
|
274
|
+
${escapeHtml(this.config.footer.copyrightText)}
|
|
275
|
+
</td></tr>
|
|
276
|
+
</table>
|
|
277
|
+
` : ''}
|
|
278
|
+
${this.config.footer?.unsubscribeText ? `
|
|
279
|
+
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
|
280
|
+
<tr><td style="${unsubscribeCellStyles}">
|
|
281
|
+
${this.config.footer.unsubscribeUrl
|
|
282
|
+
? `<a href="${safeHref(this.config.footer.unsubscribeUrl, true)}" style="color:${footerTextColor};${this.linkDecorationStyle()}">${escapeHtml(this.config.footer.unsubscribeText)}</a>`
|
|
283
|
+
: escapeHtml(this.config.footer.unsubscribeText)}
|
|
284
|
+
</td></tr>
|
|
285
|
+
</table>
|
|
286
|
+
` : ''}
|
|
287
|
+
</td></tr>
|
|
288
|
+
</table>
|
|
289
|
+
`;
|
|
290
|
+
return this;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private buildFooterLinks(): string {
|
|
294
|
+
if (!this.config.footer?.links?.length) return '';
|
|
295
|
+
const colors = this.variant === 'light' ? this.theme.light : this.theme.dark;
|
|
296
|
+
const footerBackground = this.config.footer.backgroundColor || colors.background;
|
|
297
|
+
const preferredLinkColor = contrastRatio(colors.textMuted, footerBackground) >= 4.5 ? colors.textMuted : colors.text;
|
|
298
|
+
const linkColor = readableTextColor(this.config.footer.textColor || preferredLinkColor, footerBackground, colors.text);
|
|
299
|
+
const linkStyles = `color:${linkColor};${this.linkDecorationStyle()}font-family:${this.theme.typography.fontFamily};font-size:${this.theme.typography.fontSizes.small};line-height:1.5;`;
|
|
300
|
+
return `
|
|
301
|
+
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
302
|
+
<tr><td style="padding-bottom:${this.theme.spacing.md};" align="center">
|
|
303
|
+
<table class="footer-links" cellpadding="0" cellspacing="0" role="presentation">
|
|
304
|
+
<tr>${this.config.footer.links.map(link => `
|
|
305
|
+
<td class="footer-link-cell" style="padding:0 8px;">
|
|
306
|
+
<a href="${safeHref(link.url, true)}" style="${escapeHtml(linkStyles)}">${escapeHtml(link.text)}</a>
|
|
307
|
+
</td>
|
|
308
|
+
`).join('')}</tr>
|
|
309
|
+
</table>
|
|
310
|
+
</td></tr>
|
|
311
|
+
</table>
|
|
312
|
+
`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private linkDecorationStyle(): string {
|
|
316
|
+
const decoration = this.config.linkDecoration === 'underline' ? 'underline' : 'none';
|
|
317
|
+
return `text-decoration:${decoration} !important;`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private buildSocialLinks(): string {
|
|
321
|
+
if (!this.config.footer?.socialLinks?.length) return '';
|
|
322
|
+
|
|
323
|
+
return `
|
|
324
|
+
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
|
325
|
+
<tr>
|
|
326
|
+
<td align="center" style="padding: ${this.theme.spacing.md} 0;">
|
|
327
|
+
|
|
328
|
+
<table cellpadding="0" cellspacing="0" role="presentation">
|
|
329
|
+
<tr>
|
|
330
|
+
${this.config.footer.socialLinks.map(link => `
|
|
331
|
+
<td style="padding: 0 6px;">
|
|
332
|
+
<a href="${safeHref(link.url)}" target="_blank" rel="noopener noreferrer" style="${this.linkDecorationStyle()}">
|
|
333
|
+
<img
|
|
334
|
+
src="${this.getSocialIcon(link.platform)}"
|
|
335
|
+
width="20"
|
|
336
|
+
height="20"
|
|
337
|
+
alt="${escapeHtml(link.platform)}"
|
|
338
|
+
style="display: block; border: 0;"
|
|
339
|
+
/>
|
|
340
|
+
</a>
|
|
341
|
+
</td>
|
|
342
|
+
`).join('')}
|
|
343
|
+
</tr>
|
|
344
|
+
</table>
|
|
345
|
+
|
|
346
|
+
</td>
|
|
347
|
+
</tr>
|
|
348
|
+
</table>
|
|
349
|
+
`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private getSocialIcon(platform: string): string {
|
|
353
|
+
const icons: Record<string, string> = {
|
|
354
|
+
facebook: 'https://cdn.simpleicons.org/facebook/1877F2',
|
|
355
|
+
linkedin: 'https://cdn.simpleicons.org/linkedin/0A66C2',
|
|
356
|
+
twitter: 'https://cdn.simpleicons.org/x/1DA1F2',
|
|
357
|
+
github: 'https://cdn.simpleicons.org/github/000000',
|
|
358
|
+
instagram: 'https://cdn.simpleicons.org/instagram/E4405F',
|
|
359
|
+
youtube: 'https://cdn.simpleicons.org/youtube/FF0000',
|
|
360
|
+
discord: 'https://cdn.simpleicons.org/discord/5865F2',
|
|
361
|
+
reddit: 'https://cdn.simpleicons.org/reddit/FF4500',
|
|
362
|
+
pinterest: 'https://cdn.simpleicons.org/pinterest/BD081C',
|
|
363
|
+
tiktok: 'https://cdn.simpleicons.org/tiktok/000000',
|
|
364
|
+
gitlab: 'https://cdn.simpleicons.org/gitlab/FC6D26',
|
|
365
|
+
stackoverflow: 'https://cdn.simpleicons.org/stackoverflow/F58025',
|
|
366
|
+
medium: 'https://cdn.simpleicons.org/medium/000000',
|
|
367
|
+
dribbble: 'https://cdn.simpleicons.org/dribbble/EA4C89',
|
|
368
|
+
behance: 'https://cdn.simpleicons.org/behance/1769FF',
|
|
369
|
+
telegram: 'https://cdn.simpleicons.org/telegram/26A5E4'
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
return icons[platform] || 'https://cdn.simpleicons.org/virginmedia/5865F2';
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
build(): string {
|
|
376
|
+
const colors = this.variant === 'light' ? this.theme.light : this.theme.dark;
|
|
377
|
+
const styles = this.emailStyles;
|
|
378
|
+
const direction = this.config.direction || 'ltr';
|
|
379
|
+
if (!['ltr', 'rtl', 'auto'].includes(direction)) throw new Error('Invalid template text direction');
|
|
380
|
+
const maxWidth = Number.isFinite(styles.maxWidthPx) ? Math.min(800, Math.max(320, Math.round(styles.maxWidthPx))) : 600;
|
|
381
|
+
const containerBorder = borderFor(styles.container.borderColor, styles.container.borderWidth, colors);
|
|
382
|
+
const pageBackground = this.variant === 'light' ? '#f3f4f6' : '#090d15';
|
|
383
|
+
const containerStyles = `
|
|
384
|
+
width: 100%;
|
|
385
|
+
max-width: ${maxWidth}px;
|
|
386
|
+
margin: 0 auto;
|
|
387
|
+
background-color: ${colors.background};
|
|
388
|
+
border: ${containerBorder};
|
|
389
|
+
border-radius: ${styles.container.borderRadius};
|
|
390
|
+
box-shadow: ${styles.container.boxShadow};
|
|
391
|
+
border-collapse: separate;
|
|
392
|
+
`;
|
|
393
|
+
return `
|
|
394
|
+
<!DOCTYPE html>
|
|
395
|
+
<html lang="${escapeHtml(this.config.language || 'en')}" dir="${escapeHtml(direction)}">
|
|
396
|
+
<head>
|
|
397
|
+
<meta charset="utf-8">
|
|
398
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
399
|
+
<meta name="color-scheme" content="${this.variant}">
|
|
400
|
+
<meta name="supported-color-schemes" content="${this.variant}">
|
|
401
|
+
<meta name="x-apple-disable-message-reformatting">
|
|
402
|
+
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
|
|
403
|
+
<title>Email template</title>
|
|
404
|
+
<style>
|
|
405
|
+
:root { color-scheme:${this.variant}; supported-color-schemes:${this.variant}; }
|
|
406
|
+
body { margin:0 !important; padding:0 !important; width:100% !important; background-color:${pageBackground}; }
|
|
407
|
+
table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; }
|
|
408
|
+
img { border:0; max-width:100%; height:auto; }
|
|
409
|
+
.container { width:100%; max-width:${maxWidth}px; }
|
|
410
|
+
.button { max-width:100%; }
|
|
411
|
+
@media only screen and (max-width: 600px) {
|
|
412
|
+
.outer-cell { padding:10px 8px !important; }
|
|
413
|
+
.container { width:100% !important; max-width:100% !important; }
|
|
414
|
+
.email-header-cell { padding-left:16px !important; padding-right:16px !important; }
|
|
415
|
+
.email-body { padding-left:16px !important; padding-right:16px !important; }
|
|
416
|
+
.email-footer-cell { padding-left:16px !important; padding-right:16px !important; }
|
|
417
|
+
table.button { display:block !important; width:100% !important; }
|
|
418
|
+
.button-link { box-sizing:border-box !important; display:block !important; width:100% !important; text-align:center !important; }
|
|
419
|
+
.footer-links { width:100% !important; }
|
|
420
|
+
.footer-link-cell { box-sizing:border-box !important; display:block !important; width:100% !important; padding:5px 0 !important; text-align:center !important; }
|
|
421
|
+
}
|
|
422
|
+
</style>
|
|
423
|
+
</head>
|
|
424
|
+
<body style="margin:0;padding:0;background-color:${pageBackground};">
|
|
425
|
+
<table class="email-page" width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="${pageBackground}" style="width:100%;background-color:${pageBackground};">
|
|
426
|
+
<tr><td class="outer-cell" align="center" style="padding:20px 12px;">
|
|
427
|
+
<table class="container" width="${maxWidth}" cellpadding="0" cellspacing="0" role="presentation" bgcolor="${colors.background}" style="${escapeHtml(containerStyles)}">
|
|
428
|
+
<tr><td class="container-content" style="padding:0;">
|
|
429
|
+
${this.template}
|
|
430
|
+
</td></tr>
|
|
431
|
+
</table>
|
|
432
|
+
</td></tr>
|
|
433
|
+
</table>
|
|
434
|
+
</body>
|
|
435
|
+
</html>
|
|
436
|
+
`;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { ThemeType } from '../../core/enums/theme.enum';
|
|
2
|
+
import { ITheme, IThemeColors, ITypography, ISpacing, IThemeEmailStyles } from './theme.interface';
|
|
3
|
+
|
|
4
|
+
export class AuroraTheme implements ITheme {
|
|
5
|
+
id = ThemeType.AURORA;
|
|
6
|
+
name = 'Aurora';
|
|
7
|
+
|
|
8
|
+
light: IThemeColors = {
|
|
9
|
+
primary: '#06727D',
|
|
10
|
+
secondary: '#6750A4',
|
|
11
|
+
background: '#F3F8FA',
|
|
12
|
+
text: '#112B3A',
|
|
13
|
+
textMuted: '#536B78',
|
|
14
|
+
border: '#CBD9DF',
|
|
15
|
+
success: '#18794E',
|
|
16
|
+
error: '#B42332',
|
|
17
|
+
warning: '#8A5A00'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
dark: IThemeColors = {
|
|
21
|
+
primary: '#5ADBC7',
|
|
22
|
+
secondary: '#B9A5FF',
|
|
23
|
+
background: '#0B1624',
|
|
24
|
+
text: '#EDF5F8',
|
|
25
|
+
textMuted: '#B0C0CA',
|
|
26
|
+
border: '#334454',
|
|
27
|
+
success: '#69D5A0',
|
|
28
|
+
error: '#FF8A8A',
|
|
29
|
+
warning: '#FFD166'
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
typography: ITypography = {
|
|
33
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif',
|
|
34
|
+
fontSizes: {
|
|
35
|
+
small: '14px',
|
|
36
|
+
medium: '16px',
|
|
37
|
+
large: '18px',
|
|
38
|
+
xlarge: '26px'
|
|
39
|
+
},
|
|
40
|
+
fontWeights: {
|
|
41
|
+
normal: 400,
|
|
42
|
+
medium: 500,
|
|
43
|
+
bold: 600
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
spacing: ISpacing = {
|
|
48
|
+
xs: '4px',
|
|
49
|
+
sm: '8px',
|
|
50
|
+
md: '16px',
|
|
51
|
+
lg: '24px',
|
|
52
|
+
xl: '36px'
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
emailStyles: IThemeEmailStyles = {
|
|
56
|
+
maxWidthPx: 600,
|
|
57
|
+
container: {
|
|
58
|
+
borderColor: 'border',
|
|
59
|
+
borderWidth: '1px',
|
|
60
|
+
borderRadius: '12px',
|
|
61
|
+
boxShadow: 'none'
|
|
62
|
+
},
|
|
63
|
+
header: {
|
|
64
|
+
accentColor: 'primary',
|
|
65
|
+
accentWidth: '3px',
|
|
66
|
+
horizontalPadding: '24px',
|
|
67
|
+
logoColor: 'primary',
|
|
68
|
+
imageBorderRadius: '8px'
|
|
69
|
+
},
|
|
70
|
+
body: {
|
|
71
|
+
accentColor: 'primary',
|
|
72
|
+
accentWidth: '3px',
|
|
73
|
+
borderRadius: '10px',
|
|
74
|
+
boxShadow: 'none',
|
|
75
|
+
horizontalPadding: '24px',
|
|
76
|
+
lineHeight: 1.55
|
|
77
|
+
},
|
|
78
|
+
button: {
|
|
79
|
+
appearance: 'solid',
|
|
80
|
+
borderRadius: '6px',
|
|
81
|
+
borderWidth: '1px',
|
|
82
|
+
boxShadow: 'none',
|
|
83
|
+
textTransform: 'none',
|
|
84
|
+
letterSpacing: '0.01em'
|
|
85
|
+
},
|
|
86
|
+
footer: {
|
|
87
|
+
accentColor: 'secondary',
|
|
88
|
+
accentWidth: '1px'
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|