@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.
Files changed (132) hide show
  1. package/README.md +412 -79
  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
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
+ }