init: n8n community node for sending emails rendered from Markdown via emailmd

This commit is contained in:
2026-03-28 14:10:06 +01:00
commit 6cdd6b0ba1
10 changed files with 10013 additions and 0 deletions

13
.eslintrc.js Normal file
View File

@@ -0,0 +1,13 @@
module.exports = {
root: true,
env: {
node: true,
es2020: true,
},
parser: '@typescript-eslint/parser',
plugins: ['eslint-plugin-n8n-nodes-base'],
extends: ['plugin:eslint-plugin-n8n-nodes-base/nodes'],
rules: {
'n8n-nodes-base/node-param-description-missing-from-dynamic-options': 'off',
},
};

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
*.js.map
.env

8
.prettierrc.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
semi: true,
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
tabWidth: 2,
useTabs: true,
};

102
README.md Normal file
View File

@@ -0,0 +1,102 @@
# n8n-nodes-emailmd
n8n community node do wysyłania emaili renderowanych z Markdown za pomocą [emailmd](https://github.com/unmta/emailmd).
## Funkcje
- Pisz treść emaila w Markdown zamiast HTML
- Obsługa YAML frontmatter (`preheader`, `theme`)
- Wbudowane motywy: `default`, `light`, `dark`
- Nadpisywanie kolorów i typografii motywu
- Wsparcie dla: To, CC, BCC, Reply-To, attachments
- Automatyczna generacja wersji plain-text
- Dyrektywy emailmd: `{button}`, `:::callout`, `:::hero`, `:::footer`
## Instalacja
### Opcja 1 — npm link (lokalny development)
```bash
# 1. Zbuduj node
cd /path/to/n8n-nodes-emailmd
npm install
npm run build
# 2. Zlinkuj lokalnie
npm link
# 3. W katalogu n8n
cd ~/.n8n
mkdir -p custom
cd custom
npm link n8n-nodes-emailmd
```
### Opcja 2 — Ścieżka do custom nodes w n8n
Ustaw zmienną środowiskową przed uruchomieniem n8n:
```bash
export N8N_CUSTOM_EXTENSIONS="/path/to/n8n-nodes-emailmd"
n8n start
```
### Opcja 3 — npm install (po opublikowaniu)
```bash
npm install n8n-nodes-emailmd
```
## Konfiguracja
### Credentials (SMTP)
Dodaj credentials typu **SMTP**:
- **Host** — serwer SMTP (np. `smtp.gmail.com`)
- **Port** — `465` (SSL) lub `587` (STARTTLS)
- **Secure** — `true` dla portu 465
- **User** / **Password**
### Parametry node
| Pole | Opis |
|---|---|
| From Name | Nazwa nadawcy |
| From Email | Adres nadawcy (wymagany) |
| To | Odbiorcy (przecinkami) |
| Subject | Temat (wymagany) |
| Markdown | Treść w Markdown (wymagany) |
| Theme | Motyw: default / light / dark |
| Theme Overrides | Nadpisanie kolorów i fontów |
| Attachments | Nazwy binary properties |
## Przykładowy Markdown
```markdown
---
preheader: Krótki podgląd w skrzynce odbiorczej
---
# Witaj {{$json.name}}!
Dziękujemy za rejestrację. Twoje konto jest gotowe.
:::callout
Twój kod aktywacyjny: **{{$json.code}}**
:::
[Aktywuj konto](https://example.com/activate){button}
---
Jeśli masz pytania, odpowiedz na tego emaila.
```
## Obsługiwana składnia emailmd
- **Przyciski**: `[tekst](url){button}` lub `{button .success}`, `{button .danger}`
- **Callout**: `:::callout ... :::`
- **Hero**: `:::hero ... :::`
- **Header/Footer**: `:::header ... :::`, `:::footer ... :::`
- **Wyrównanie**: `:::centered ... :::`
- **Tabele**, listy, kod, obrazy — standardowy Markdown

View File

@@ -0,0 +1,44 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class SmtpCredential implements ICredentialType {
name = 'smtp';
displayName = 'SMTP';
documentationUrl = 'https://docs.n8n.io/integrations/builtin/credentials/sendemail/';
properties: INodeProperties[] = [
{
displayName: 'Host',
name: 'host',
type: 'string',
default: '',
placeholder: 'smtp.example.com',
},
{
displayName: 'Port',
name: 'port',
type: 'number',
default: 465,
},
{
displayName: 'Secure',
name: 'secure',
type: 'boolean',
default: true,
description: 'Whether to use SSL/TLS',
},
{
displayName: 'User',
name: 'user',
type: 'string',
default: '',
},
{
displayName: 'Password',
name: 'password',
type: 'string',
typeOptions: {
password: true,
},
default: '',
},
];
}

7
gulpfile.js Normal file
View File

@@ -0,0 +1,7 @@
const { src, dest } = require('gulp');
function copyIcons() {
return src('nodes/**/*.{png,svg}').pipe(dest('dist/nodes'));
}
exports['build:icons'] = copyIcons;

View File

@@ -0,0 +1,312 @@
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<INodeExecutionData[][]> {
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<string, string>;
const attachmentProperties = this.getNodeParameter('attachments', i) as string;
// Build theme object
const themeOptions: Record<string, string> = {};
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<string, unknown> };
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];
}
}

9445
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "n8n-nodes-emailmd",
"version": "0.1.0",
"description": "n8n node for sending emails rendered from Markdown using emailmd",
"keywords": [
"n8n-community-node-package",
"n8n",
"email",
"markdown",
"emailmd"
],
"license": "MIT",
"homepage": "https://github.com/unmta/emailmd",
"author": {
"name": "n8n-nodes-emailmd"
},
"repository": {
"type": "git",
"url": "git+https://github.com/unmta/emailmd.git"
},
"main": "index.js",
"scripts": {
"build": "tsc && gulp build:icons",
"dev": "tsc --watch",
"format": "prettier nodes --write",
"lint": "eslint nodes --ext .ts",
"lintfix": "eslint nodes --ext .ts --fix",
"prepublishOnly": "npm run build && npm run lint -c .eslintrc.prepublish.js"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [
"dist/credentials/SmtpCredential.credentials.js"
],
"nodes": [
"dist/nodes/EmailMd/EmailMd.node.js"
]
},
"devDependencies": {
"@types/nodemailer": "^6.4.17",
"@typescript-eslint/parser": "^5.62.0",
"eslint-plugin-n8n-nodes-base": "^1.16.2",
"gulp": "^4.0.2",
"n8n-workflow": "*",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
},
"dependencies": {
"emailmd": "^0.1.0",
"nodemailer": "^6.9.16"
},
"engines": {
"node": ">=18.0",
"npm": ">=8.0"
}
}

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2019",
"lib": ["es2019", "es2020.promise", "es2020.bigint", "es2020.string"],
"outDir": "./dist",
"rootDir": "./",
"typeRoots": ["./node_modules/@types"],
"esModuleInterop": true,
"resolveJsonModule": true,
"sourceMap": true,
"declaration": true,
"skipLibCheck": true
},
"include": ["credentials/**/*.ts", "nodes/**/*.ts"],
"exclude": ["node_modules", "dist"]
}