import type { IExecuteFunctions, INodeExecutionData, INodeType, INodeTypeDescription, } from 'n8n-workflow'; import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { render } from 'emailmd'; import nodemailer from 'nodemailer'; import type Mail from 'nodemailer/lib/mailer'; export class EmailMd implements INodeType { description: INodeTypeDescription = { displayName: 'Send Email (Markdown)', name: 'emailMd', icon: 'fa:envelope', group: ['output'], version: 1, description: 'Renders a Markdown template with emailmd and sends it via SMTP', defaults: { name: 'Send Email (Markdown)', }, inputs: [NodeConnectionTypes.Main], outputs: [NodeConnectionTypes.Main], credentials: [ { name: 'smtp', required: true, }, ], properties: [ // ─── Recipients ──────────────────────────────────────────────────── { displayName: 'From Name', name: 'fromName', type: 'string', default: '', placeholder: 'John Doe', description: 'Sender display name', }, { displayName: 'From Email', name: 'fromEmail', type: 'string', default: '', placeholder: 'sender@example.com', description: 'Sender email address', required: true, }, { displayName: 'To', name: 'toEmail', type: 'string', default: '', placeholder: 'recipient@example.com', description: 'Comma-separated list of recipients', required: true, }, { displayName: 'Subject', name: 'subject', type: 'string', default: '', placeholder: 'Hello from n8n!', required: true, }, { displayName: 'Reply To', name: 'replyTo', type: 'string', default: '', placeholder: 'reply@example.com', }, { displayName: 'CC', name: 'cc', type: 'string', default: '', placeholder: 'cc@example.com', }, { displayName: 'BCC', name: 'bcc', type: 'string', default: '', placeholder: 'bcc@example.com', }, // ─── Markdown Content ─────────────────────────────────────────────── { displayName: 'Markdown', name: 'markdown', type: 'string', typeOptions: { rows: 20, }, default: `--- preheader: Preview text shown in email clients --- # Hello {{name}}! Welcome to our service. Here's what you need to know. [Get Started](https://example.com){button} `, description: 'Markdown content to render. Supports YAML frontmatter for preheader and theme settings. Expressions like {{$json.name}} are evaluated before rendering.', required: true, noDataExpression: false, }, // ─── Theme Options ────────────────────────────────────────────────── { displayName: 'Theme', name: 'theme', type: 'options', options: [ { name: 'Default', value: 'default' }, { name: 'Light', value: 'light' }, { name: 'Dark', value: 'dark' }, ], default: 'default', description: 'Base theme for the email', }, { displayName: 'Theme Overrides', name: 'themeOverrides', type: 'collection', placeholder: 'Add Override', default: {}, description: 'Custom theme color and typography overrides', options: [ { displayName: 'Brand Color', name: 'brandColor', type: 'color', default: '#1a56db', }, { displayName: 'Background Color', name: 'backgroundColor', type: 'color', default: '#f4f4f5', }, { displayName: 'Content Background', name: 'contentBackground', type: 'color', default: '#ffffff', }, { displayName: 'Text Color', name: 'bodyColor', type: 'color', default: '#374151', }, { displayName: 'Heading Color', name: 'headingColor', type: 'color', default: '#111827', }, { displayName: 'Font Family', name: 'fontFamily', type: 'string', default: '', placeholder: 'Arial, sans-serif', }, { displayName: 'Content Width', name: 'contentWidth', type: 'string', default: '600px', placeholder: '600px', }, ], }, // ─── Advanced ─────────────────────────────────────────────────────── { displayName: 'Attachments', name: 'attachments', type: 'string', default: '', description: 'Comma-separated list of binary property names that contain the attachment data', placeholder: 'attachment1, attachment2', }, ], }; async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; const credentials = await this.getCredentials('smtp'); const transporter = nodemailer.createTransport({ host: credentials.host as string, port: credentials.port as number, secure: credentials.secure as boolean, auth: { user: credentials.user as string, pass: credentials.password as string, }, }); for (let i = 0; i < items.length; i++) { try { const fromName = this.getNodeParameter('fromName', i) as string; const fromEmail = this.getNodeParameter('fromEmail', i) as string; const toEmail = this.getNodeParameter('toEmail', i) as string; const subject = this.getNodeParameter('subject', i) as string; const replyTo = this.getNodeParameter('replyTo', i) as string; const cc = this.getNodeParameter('cc', i) as string; const bcc = this.getNodeParameter('bcc', i) as string; const markdown = this.getNodeParameter('markdown', i) as string; const theme = this.getNodeParameter('theme', i) as string; const themeOverrides = this.getNodeParameter('themeOverrides', i) as Record; const attachmentProperties = this.getNodeParameter('attachments', i) as string; // Build theme object const themeOptions: Record = {}; if (theme !== 'default') { themeOptions.theme = theme; } for (const [key, value] of Object.entries(themeOverrides)) { if (value) { themeOptions[key] = value; } } // Render markdown -> HTML + plain text let rendered: { html: string; text: string; meta: Record }; try { rendered = render(markdown, { theme: Object.keys(themeOptions).length > 0 ? themeOptions : undefined, }); } catch (renderError) { throw new NodeOperationError( this.getNode(), `emailmd render error: ${(renderError as Error).message}`, { itemIndex: i }, ); } // Resolve subject from meta preheader if not set, or use provided const resolvedSubject = subject || (rendered.meta?.subject as string) || '(no subject)'; // Build from address const from = fromName ? `"${fromName}" <${fromEmail}>` : fromEmail; // Handle attachments const attachments: Mail.Attachment[] = []; if (attachmentProperties.trim()) { const propNames = attachmentProperties.split(',').map((p) => p.trim()).filter(Boolean); for (const propName of propNames) { const binaryData = this.helpers.assertBinaryData(i, propName); const binaryBuffer = await this.helpers.getBinaryDataBuffer(i, propName); attachments.push({ filename: binaryData.fileName || propName, content: binaryBuffer, contentType: binaryData.mimeType, }); } } // Send email const mailOptions: Mail.Options = { from, to: toEmail, subject: resolvedSubject, html: rendered.html, text: rendered.text, ...(replyTo && { replyTo }), ...(cc && { cc }), ...(bcc && { bcc }), ...(attachments.length > 0 && { attachments }), }; const info = await transporter.sendMail(mailOptions); returnData.push({ json: { messageId: info.messageId, accepted: info.accepted, rejected: info.rejected, pending: info.pending, response: info.response, subject: resolvedSubject, to: toEmail, from, }, pairedItem: { item: i }, }); } catch (error) { if (this.continueOnFail()) { returnData.push({ json: { error: (error as Error).message }, pairedItem: { item: i }, }); continue; } throw error; } } return [returnData]; } }