@brainspore/shackuz
Version:
Nodejs Email Templating Engine
241 lines (240 loc) • 8.45 kB
JavaScript
export class EmailTemplateBuilder {
theme = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
blocks = [];
appConfig = {};
styles = '';
customFooter = '';
constructor(config = {}) {
this.theme = {
fontFamily: 'Helvetica Neue\', Arial, sans-serif',
backgroundColor: '#f4f6f8',
textColor: '#333333',
primaryColor: '#2563eb',
buttonGradient: 'linear-gradient(90deg, #3b82f6 0%, #2563eb 100%)',
secondaryColor: '#1d4ed8',
...config.theme,
};
this.blocks = [];
this.appConfig = {
appName: process.env.APP_NAME || 'SHACKUZ',
appUrl: process.env.APP_URL || "http://localhost:3500",
year: new Date().getFullYear(),
...config.appConfig,
};
// Handle custom styles
if (typeof config.styles === 'string') {
this.styles = config.styles;
}
else if (typeof config.styles === 'object' && config.styles !== null) {
this.styles = EmailTemplateBuilder.objectToCSS(config.styles);
}
else {
this.styles = EmailTemplateBuilder.defaultStyles(this.theme);
}
}
/**
* Set custom footer content (HTML string or plain text)
* @param content string
*/
setFooter(content) {
this.customFooter = content;
return this;
}
// Helper to convert camelCase to kebab-case
static toKebabCase(str) {
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
}
// Helper to convert a JS object to CSS string
static objectToCSS(stylesObj) {
let css = '';
for (const selector in stylesObj) {
css += `${selector} {`;
for (const [prop, value] of Object.entries(stylesObj[selector])) {
css += `${EmailTemplateBuilder.toKebabCase(prop)}: ${value};`;
}
css += '}\n';
}
return css;
}
// Default styles as a function for easy override
static defaultStyles(theme) {
return `
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: ${theme.fontFamily};
background-color: ${theme.backgroundColor};
line-height: 1.6;
color: ${theme.textColor};
padding: 20px;
}
.container {
max-width: 1000px;
width: 100%;
margin: 0 auto;
}
main {
background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%);
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
padding: 40px;
border: 1px solid #e5e7eb;
margin-bottom: 20px;
}
footer {
text-align: center;
font-size: 14px;
color: #6b7280;
padding: 20px;
}
@media (max-width: 640px) {
.container { padding: 10px; }
main, footer { padding: 20px; }
h1 { font-size: 24px; }
a { display: block; text-align: center; }
}
`;
}
static blocks = {
h: (text, styles = {}) => {
const defaultStyles = {
fontSize: '28px',
fontWeight: 600,
color: '#1a1a1a',
marginBottom: '20px',
letterSpacing: '-0.5px',
};
// Merge default and user styles, user styles take precedence
const mergedStyles = { ...defaultStyles, ...styles };
const stylesArray = [];
for (const [key, value] of Object.entries(mergedStyles)) {
stylesArray.push(`${EmailTemplateBuilder.toKebabCase(key)}: ${value}`);
}
return `
<h1 style="${stylesArray.join('; ')}">
${text}
</h1>
`;
},
p: (text, styles = {}) => {
const defaultStyles = {
fontSize: '16px',
color: '#4b5563',
marginBottom: '20px',
};
// Merge default and user styles, user styles take precedence
const mergedStyles = { ...defaultStyles, ...styles };
const stylesArray = [];
for (const [key, value] of Object.entries(mergedStyles)) {
stylesArray.push(`${EmailTemplateBuilder.toKebabCase(key)}: ${value}`);
}
return `
<p style="${stylesArray.join('; ')}">
${text}
</p>
`;
},
b: (text, url, styles = {}) => {
const defaultStyles = {
display: 'inline-block',
background: 'linear-gradient(90deg, #3b82f6 0%, #2563eb 100%)',
fontSize: '16px',
fontWeigth: 500,
color: '#ffffff',
textDecoration: 'none',
borderRadius: '8px',
padding: '12px 24px',
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
};
// Merge default and user styles, user styles take precedence
const mergedStyles = { ...defaultStyles, ...styles };
const stylesArray = [];
for (const [key, value] of Object.entries(mergedStyles)) {
stylesArray.push(`${EmailTemplateBuilder.toKebabCase(key)}: ${value}`);
}
return `
<a href="${url}" style="${stylesArray.join('; ')}"
onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 12px rgba(59, 130, 246, 0.3)';"
onmouseout="this.style.transform=''; this.style.boxShadow='';">
${text}
</a>
`;
},
d: (styles = {}) => {
const defaultStyles = {
border: 'none',
margin: '20px 0'
};
// Merge default and user styles, user styles take precedence
const mergedStyles = { ...defaultStyles, ...styles };
const stylesArray = [];
for (const [key, value] of Object.entries(mergedStyles)) {
stylesArray.push(`${EmailTemplateBuilder.toKebabCase(key)}: ${value}`);
}
return `
<hr style="${stylesArray.join('; ')}" />
`;
},
link: (link, styles = {}) => {
const defaultStyles = {
textDecoration: 'underline',
};
// Merge default and user styles, user styles take precedence
const mergedStyles = { ...defaultStyles, ...styles };
const stylesArray = [];
for (const [key, value] of Object.entries(mergedStyles)) {
stylesArray.push(`${EmailTemplateBuilder.toKebabCase(key)}: ${value}`);
}
return `
<a href="${link}" style="${stylesArray.join('; ')}">${link}</a>
</p>
`;
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
addBlock(type, ...args) {
if (!EmailTemplateBuilder.blocks[type]) {
throw new Error(`Invalid block type: ${type}`);
}
this.blocks.push(EmailTemplateBuilder.blocks[type](...this.sanitizeArgs(args)));
return this;
}
buildHTML() {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${this.appConfig.title}</title>
<style>
${this.styles}
</style>
</head>
<body>
<div class="container">
<main>
${this.blocks.join('')}
</main>
<footer>
${this.customFooter ? `<div class="custom-footer">${this.customFooter}</div>` : ''}
<p><a href="${this.appConfig.appUrl}">${this.appConfig.appName}</a></p>
<p>© ${this.appConfig.year}. All rights reserved.</p>
</footer>
</div>
</body>
</html>
`;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sanitizeArgs(args) {
return args.map(arg => typeof arg === 'string'
? arg.replace(/[<>&"]/g, match => ({
'<': '<',
'>': '>',
'&': '&',
'"': '"',
}[match] || ''))
: arg);
}
}