init: n8n community node for sending emails rendered from Markdown via emailmd
This commit is contained in:
13
.eslintrc.js
Normal file
13
.eslintrc.js
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.js.map
|
||||
.env
|
||||
8
.prettierrc.js
Normal file
8
.prettierrc.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
useTabs: true,
|
||||
};
|
||||
102
README.md
Normal file
102
README.md
Normal 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
|
||||
44
credentials/SmtpCredential.credentials.ts
Normal file
44
credentials/SmtpCredential.credentials.ts
Normal 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
7
gulpfile.js
Normal 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;
|
||||
312
nodes/EmailMd/EmailMd.node.ts
Normal file
312
nodes/EmailMd/EmailMd.node.ts
Normal 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
9445
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
package.json
Normal file
59
package.json
Normal 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
19
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user