better-svelte-email
Version:
Svelte email renderer with Tailwind support
215 lines (214 loc) • 8.01 kB
JavaScript
import { Resend } from 'resend';
import fs from 'fs';
import { render } from 'svelte/server';
import path from 'path';
import prettier from 'prettier/standalone';
import parserHtml from 'prettier/parser-html';
/**
* Get a list of all email component files in the specified directory.
*
* @param options.path - Relative path from root to emails folder (default: '/src/lib/emails')
* @param options.root - Absolute path to project root (auto-detected if not provided)
* @returns PreviewData object with list of email files and the path
*
* @example
* ```ts
* // In a +page.server.ts file
* import { emailList } from 'better-svelte-email/preview';
*
* export function load() {
* const emails = emailList({
* root: process.cwd(),
* path: '/src/lib/emails'
* });
* return { emails };
* }
* ```
*/
export const emailList = ({ path: emailPath = '/src/lib/emails', root } = {}) => {
// If root is not provided, try to use process.cwd()
if (!root) {
try {
root = process.cwd();
}
catch {
throw new Error('Could not determine the root path of your project. Please pass in the root param manually using process.cwd() or an absolute path');
}
}
const fullPath = path.join(root, emailPath);
// Check if directory exists
if (!fs.existsSync(fullPath)) {
console.warn(`Email directory not found: ${fullPath}`);
return { files: null, path: emailPath };
}
const files = createEmailComponentList(emailPath, getFiles(fullPath));
if (!files.length) {
return { files: null, path: emailPath };
}
return { files, path: emailPath };
};
const getEmailComponent = async (emailPath, file) => {
try {
// Import the email component dynamically
return (await import(/* @vite-ignore */ `${emailPath}/${file}.svelte`)).default;
}
catch {
throw new Error(`Failed to import email component '${file}'. Make sure the file exists and includes the <Head /> component.`);
}
};
/**
* SvelteKit form action to render an email component.
* Use this with the Preview component to render email templates on demand.
*
* @example
* ```ts
* // +page.server.ts
* import { createEmail } from 'better-svelte-email/preview';
*
* export const actions = createEmail;
* ```
*/
export const createEmail = {
'create-email': async (event) => {
try {
const data = await event.request.formData();
const file = data.get('file');
const emailPath = data.get('path');
if (!file || !emailPath) {
return {
status: 400,
body: { error: 'Missing file or path parameter' }
};
}
const emailComponent = await getEmailComponent(emailPath, file);
// Render the component to HTML
const { body } = render(emailComponent);
// Remove all HTML comments from the body before formatting
const bodyWithoutComments = body.replace(/<!--[\s\S]*?-->/g, '');
const formattedBody = await prettier.format(bodyWithoutComments, {
parser: 'html',
plugins: [parserHtml]
});
return {
body: formattedBody
};
}
catch (error) {
console.error('Error rendering email:', error);
return {
status: 500,
error: {
message: error instanceof Error ? error.message : 'Failed to render email'
}
};
}
}
};
const defaultSendEmailFunction = async ({ from, to, subject, html }, resendApiKey) => {
// stringify api key to comment out temp
const resend = new Resend(resendApiKey);
const email = { from, to, subject, html };
const resendReq = await resend.emails.send(email);
if (resendReq.error) {
return { success: false, error: resendReq.error };
}
else {
return { success: true, error: null };
}
};
/**
* Sends the email using the submitted form data.
*
* @param options.resendApiKey - Your Resend API key (keep this server-side only)
* @param options.customSendEmailFunction - Optional custom function to send emails
*
* @example
* ```ts
* // In +page.server.ts
* import { PRIVATE_RESEND_API_KEY } from '$env/static/private';
*
* export const actions = {
* ...createEmail,
* ...sendEmail({ resendApiKey: PRIVATE_RESEND_API_KEY })
* };
* ```
*/
export const sendEmail = ({ customSendEmailFunction, resendApiKey } = {}) => {
return {
'send-email': async (event) => {
const data = await event.request.formData();
const emailPath = data.get('path');
const file = data.get('file');
if (!file || !emailPath) {
return {
success: false,
error: { message: 'Missing file or path parameter' }
};
}
const emailComponent = await getEmailComponent(emailPath, file);
const email = {
from: 'svelte-email-tailwind <onboarding@resend.dev>',
to: `${data.get('to')}`,
subject: `${data.get('component')} ${data.get('note') ? '| ' + data.get('note') : ''}`,
html: (await render(emailComponent)).body
};
let sent = { success: false, error: null };
if (!customSendEmailFunction && resendApiKey) {
sent = await defaultSendEmailFunction(email, resendApiKey);
}
else if (customSendEmailFunction) {
sent = await customSendEmailFunction(email, resendApiKey);
}
else if (!customSendEmailFunction && !resendApiKey) {
const error = {
message: 'Resend API key not configured. Please pass your API key to the sendEmail() function in your +page.server.ts file.'
};
return { success: false, error };
}
if (sent && sent.error) {
console.log('Error:', sent.error);
return { success: false, error: sent.error };
}
else {
console.log('Email was sent successfully.');
return { success: true, error: null };
}
}
};
};
// Recursive function to get files
function getFiles(dir, files = []) {
// Get an array of all files and directories in the passed directory using fs.readdirSync
const fileList = fs.readdirSync(dir);
// Create the full path of the file/directory by concatenating the passed directory and file/directory name
for (const file of fileList) {
const name = `${dir}/${file}`;
// Check if the current file/directory is a directory using fs.statSync
if (fs.statSync(name).isDirectory()) {
// If it is a directory, recursively call the getFiles function with the directory path and the files array
getFiles(name, files);
}
else {
// If it is a file, push the full path to the files array
files.push(name);
}
}
return files;
}
/**
* Creates an array of names from the record of svelte email component file paths
*/
function createEmailComponentList(root, paths) {
const emailComponentList = [];
paths.forEach((filePath) => {
if (filePath.includes(`.svelte`)) {
const fileName = filePath.substring(filePath.indexOf(root) + root.length + 1, filePath.indexOf('.svelte'));
emailComponentList.push(fileName);
}
});
return emailComponentList;
}
// Export the Preview component
// Note: The component is available via: import EmailPreview from 'better-svelte-email/preview/EmailPreview.svelte'
// or: import { EmailPreview } from 'better-svelte-email/preview'
export { default as EmailPreview } from './EmailPreview.svelte';