vintasend-react-email 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +162 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/react-email-template-renderer.d.ts +25 -0
- package/dist/react-email-template-renderer.d.ts.map +1 -0
- package/dist/react-email-template-renderer.js +135 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# vintasend-react-email
|
|
2
|
+
|
|
3
|
+
React Email template renderer for VintaSend.
|
|
4
|
+
|
|
5
|
+
This package provides a `BaseEmailTemplateRenderer` implementation that can:
|
|
6
|
+
|
|
7
|
+
- load templates from file paths (`.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, `.cts`)
|
|
8
|
+
- compile uncompiled TypeScript/TSX templates at runtime
|
|
9
|
+
- render React body templates to HTML via `@react-email/render`
|
|
10
|
+
- render templates from inline content (including loops, conditionals, and arbitrary logic)
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install vintasend-react-email
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Exports
|
|
19
|
+
|
|
20
|
+
- `ReactEmailTemplateRenderer`
|
|
21
|
+
- `ReactEmailTemplateRendererFactory`
|
|
22
|
+
|
|
23
|
+
## How it works
|
|
24
|
+
|
|
25
|
+
### File-based templates (`render`)
|
|
26
|
+
|
|
27
|
+
When VintaSend sends a notification, the renderer reads:
|
|
28
|
+
|
|
29
|
+
- `notification.bodyTemplate`
|
|
30
|
+
- `notification.subjectTemplate`
|
|
31
|
+
|
|
32
|
+
Each file must export a function as either:
|
|
33
|
+
|
|
34
|
+
- default export: `export default function Template(context) { ... }`
|
|
35
|
+
- named export: `export function render(context) { ... }`
|
|
36
|
+
|
|
37
|
+
The function receives the notification context and returns:
|
|
38
|
+
|
|
39
|
+
- for **subject**: usually a string
|
|
40
|
+
- for **body**: a React element or a string
|
|
41
|
+
|
|
42
|
+
If body returns a React element, it is converted to HTML.
|
|
43
|
+
|
|
44
|
+
### Inline templates (`renderFromTemplateContent`)
|
|
45
|
+
|
|
46
|
+
Inline `templateContent.subject` and `templateContent.body` are compiled and executed at runtime.
|
|
47
|
+
You can pass either:
|
|
48
|
+
|
|
49
|
+
1. **Full module source** (with `export default` or `export function render`), or
|
|
50
|
+
2. **Inline snippet**:
|
|
51
|
+
- function body (`const x = ...; if (...) ...; return ...;`)
|
|
52
|
+
- or expression (`<div>Hello</div>`, `'Subject text'`)
|
|
53
|
+
|
|
54
|
+
This allows logic such as `if/else`, loops, mapping arrays, etc.
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
### 1) Create renderer instance
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { ReactEmailTemplateRendererFactory } from 'vintasend-react-email';
|
|
62
|
+
|
|
63
|
+
const templateRenderer = new ReactEmailTemplateRendererFactory<MyConfig>().create();
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 2) Use with your adapter (example)
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// Example with an adapter that accepts a BaseEmailTemplateRenderer implementation
|
|
70
|
+
const adapter = new SomeEmailAdapterFactory<MyConfig>().create(templateRenderer);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 3) Template files
|
|
74
|
+
|
|
75
|
+
`subject-template.ts`:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
export default function SubjectTemplate(context: { name?: string }) {
|
|
79
|
+
return `Welcome ${context.name ?? ''}`;
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`body-template.tsx`:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import React from 'react';
|
|
87
|
+
|
|
88
|
+
export default function BodyTemplate(context: {
|
|
89
|
+
name?: string;
|
|
90
|
+
items?: string[];
|
|
91
|
+
isVip?: boolean;
|
|
92
|
+
}) {
|
|
93
|
+
const items = context.items ?? [];
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div>
|
|
97
|
+
<h1>Hello {context.name ?? ''}</h1>
|
|
98
|
+
{context.isVip ? <p>VIP user</p> : <p>Standard user</p>}
|
|
99
|
+
<ul>
|
|
100
|
+
{items.map((item, index) => (
|
|
101
|
+
<li key={index}>{item}</li>
|
|
102
|
+
))}
|
|
103
|
+
</ul>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Inline content examples
|
|
110
|
+
|
|
111
|
+
### Full module source
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const templateContent = {
|
|
115
|
+
subject: `
|
|
116
|
+
export default function Subject(context) {
|
|
117
|
+
return ` + '`' + `Welcome ${context.name ?? ''}` + '`' + `;
|
|
118
|
+
}
|
|
119
|
+
`,
|
|
120
|
+
body: `
|
|
121
|
+
import React from 'react';
|
|
122
|
+
|
|
123
|
+
export default function Body(context) {
|
|
124
|
+
return <p>Hello {context.name ?? ''}</p>;
|
|
125
|
+
}
|
|
126
|
+
`,
|
|
127
|
+
};
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Snippet (function body)
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
const templateContent = {
|
|
134
|
+
subject: `
|
|
135
|
+
const tier = context.isVip ? 'VIP' : 'User';
|
|
136
|
+
return ` + '`' + `Welcome ${tier} ${context.name ?? ''}` + '`' + `;
|
|
137
|
+
`,
|
|
138
|
+
body: `
|
|
139
|
+
const items = Array.isArray(context.items) ? context.items : [];
|
|
140
|
+
return (
|
|
141
|
+
<ul>
|
|
142
|
+
{items.map((item, index) => (
|
|
143
|
+
<li key={index}>{String(item)}</li>
|
|
144
|
+
))}
|
|
145
|
+
</ul>
|
|
146
|
+
);
|
|
147
|
+
`,
|
|
148
|
+
};
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Validation behavior
|
|
152
|
+
|
|
153
|
+
- throws if `subjectTemplate` (file-based rendering) is missing
|
|
154
|
+
- throws if `templateContent.subject` (inline rendering) is missing
|
|
155
|
+
- throws if template file/content does not export a function
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
npm test
|
|
161
|
+
npm run build
|
|
162
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,0BAA0B,EAAE,iCAAiC,EAAE,MAAM,iCAAiC,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ReactEmailTemplateRenderer, ReactEmailTemplateRendererFactory } from './react-email-template-renderer';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { BaseEmailTemplateRenderer, EmailTemplate, EmailTemplateContent } from 'vintasend/dist/services/notification-template-renderers/base-email-template-renderer';
|
|
2
|
+
import type { JsonObject } from 'vintasend/dist/types/json-values';
|
|
3
|
+
import type { BaseLogger } from 'vintasend/dist/services/loggers/base-logger';
|
|
4
|
+
import type { AnyNotification, DatabaseNotification } from 'vintasend/dist/types/notification';
|
|
5
|
+
import type { BaseNotificationTypeConfig } from 'vintasend/dist/types/notification-type-config';
|
|
6
|
+
export declare class ReactEmailTemplateRenderer<Config extends BaseNotificationTypeConfig> implements BaseEmailTemplateRenderer<Config> {
|
|
7
|
+
private logger;
|
|
8
|
+
injectLogger(logger: BaseLogger): void;
|
|
9
|
+
render(notification: DatabaseNotification<Config>, context: JsonObject): Promise<EmailTemplate>;
|
|
10
|
+
renderFromTemplateContent(notification: AnyNotification<Config>, templateContent: EmailTemplateContent, context: JsonObject): Promise<EmailTemplate>;
|
|
11
|
+
private loadTemplateFactory;
|
|
12
|
+
private compileTemplateToCacheFile;
|
|
13
|
+
private compileSourceToCacheFile;
|
|
14
|
+
private loadTemplateFactoryFromContent;
|
|
15
|
+
private buildContentModuleSource;
|
|
16
|
+
private looksLikeTemplateModule;
|
|
17
|
+
private looksLikeFunctionBody;
|
|
18
|
+
private getEsbuildLoader;
|
|
19
|
+
private renderBodyOutput;
|
|
20
|
+
private renderSubjectOutput;
|
|
21
|
+
}
|
|
22
|
+
export declare class ReactEmailTemplateRendererFactory<Config extends BaseNotificationTypeConfig> {
|
|
23
|
+
create(): ReactEmailTemplateRenderer<Config>;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=react-email-template-renderer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react-email-template-renderer.d.ts","sourceRoot":"","sources":["../src/react-email-template-renderer.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,yBAAyB,EACzB,aAAa,EACb,oBAAoB,EACrB,MAAM,sFAAsF,CAAC;AAC9F,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kCAAkC,CAAC;AACnE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6CAA6C,CAAC;AAC9E,OAAO,KAAK,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC/F,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,+CAA+C,CAAC;AAYhG,qBAAa,0BAA0B,CAAC,MAAM,SAAS,0BAA0B,CAC/E,YAAW,yBAAyB,CAAC,MAAM,CAAC;IAE5C,OAAO,CAAC,MAAM,CAA2B;IAEzC,YAAY,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI;IAIhC,MAAM,CACV,YAAY,EAAE,oBAAoB,CAAC,MAAM,CAAC,EAC1C,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,aAAa,CAAC;IAoBnB,yBAAyB,CAC7B,YAAY,EAAE,eAAe,CAAC,MAAM,CAAC,EACrC,eAAe,EAAE,oBAAoB,EACrC,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,aAAa,CAAC;YA0BX,mBAAmB;YAenB,0BAA0B;YAK1B,wBAAwB;YAyBxB,8BAA8B;IAiB5C,OAAO,CAAC,wBAAwB;IAmBhC,OAAO,CAAC,uBAAuB;IAI/B,OAAO,CAAC,qBAAqB;IAM7B,OAAO,CAAC,gBAAgB;YAQV,gBAAgB;IAQ9B,OAAO,CAAC,mBAAmB;CAO5B;AAED,qBAAa,iCAAiC,CAAC,MAAM,SAAS,0BAA0B;IACtF,MAAM;CAGP"}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, extname, join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { render as renderReactEmail } from '@react-email/render';
|
|
7
|
+
import { transform } from 'esbuild';
|
|
8
|
+
const TS_TEMPLATE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']);
|
|
9
|
+
const COMPILED_TEMPLATE_CACHE_DIR = join(tmpdir(), 'vintasend-react-email-templates');
|
|
10
|
+
export class ReactEmailTemplateRenderer {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.logger = null;
|
|
13
|
+
}
|
|
14
|
+
injectLogger(logger) {
|
|
15
|
+
this.logger = logger;
|
|
16
|
+
}
|
|
17
|
+
async render(notification, context) {
|
|
18
|
+
this.logger?.info(`Rendering email template for notification ${notification.id}`);
|
|
19
|
+
const bodyTemplateFactory = await this.loadTemplateFactory(notification.bodyTemplate);
|
|
20
|
+
if (!notification.subjectTemplate) {
|
|
21
|
+
this.logger?.info('Subject template missing');
|
|
22
|
+
throw new Error('Subject template is required');
|
|
23
|
+
}
|
|
24
|
+
const subjectTemplateFactory = await this.loadTemplateFactory(notification.subjectTemplate);
|
|
25
|
+
const bodyOutput = await bodyTemplateFactory(context);
|
|
26
|
+
const subjectOutput = await subjectTemplateFactory(context);
|
|
27
|
+
return {
|
|
28
|
+
subject: this.renderSubjectOutput(subjectOutput),
|
|
29
|
+
body: await this.renderBodyOutput(bodyOutput),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async renderFromTemplateContent(notification, templateContent, context) {
|
|
33
|
+
this.logger?.info(`Rendering email template from content for notification ${notification.id}`);
|
|
34
|
+
if (!templateContent.subject) {
|
|
35
|
+
this.logger?.info('Subject template content missing');
|
|
36
|
+
throw new Error('Subject template is required');
|
|
37
|
+
}
|
|
38
|
+
const subjectTemplateFactory = await this.loadTemplateFactoryFromContent(templateContent.subject, 'subject');
|
|
39
|
+
const bodyTemplateFactory = await this.loadTemplateFactoryFromContent(templateContent.body, 'body');
|
|
40
|
+
const subjectOutput = await subjectTemplateFactory(context);
|
|
41
|
+
const bodyOutput = await bodyTemplateFactory(context);
|
|
42
|
+
return {
|
|
43
|
+
subject: this.renderSubjectOutput(subjectOutput),
|
|
44
|
+
body: await this.renderBodyOutput(bodyOutput),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
async loadTemplateFactory(templatePath) {
|
|
48
|
+
const importPath = TS_TEMPLATE_EXTENSIONS.has(extname(templatePath).toLowerCase())
|
|
49
|
+
? await this.compileTemplateToCacheFile(templatePath)
|
|
50
|
+
: templatePath;
|
|
51
|
+
const templateModule = (await import(pathToFileURL(importPath).href));
|
|
52
|
+
const templateExport = templateModule.default ?? templateModule.render;
|
|
53
|
+
if (typeof templateExport !== 'function') {
|
|
54
|
+
throw new Error(`Template at \"${templatePath}\" must export a function`);
|
|
55
|
+
}
|
|
56
|
+
return templateExport;
|
|
57
|
+
}
|
|
58
|
+
async compileTemplateToCacheFile(templatePath) {
|
|
59
|
+
const source = await readFile(templatePath, 'utf8');
|
|
60
|
+
return this.compileSourceToCacheFile(source, templatePath, this.getEsbuildLoader(templatePath));
|
|
61
|
+
}
|
|
62
|
+
async compileSourceToCacheFile(source, sourceIdentifier, loader) {
|
|
63
|
+
const sourceHash = createHash('sha1').update(`${sourceIdentifier}:${source}`).digest('hex');
|
|
64
|
+
const extension = extname(sourceIdentifier).replace('.', '') || loader;
|
|
65
|
+
const cacheFileName = `${sourceHash}-${extension}.mjs`;
|
|
66
|
+
const compiledPath = join(COMPILED_TEMPLATE_CACHE_DIR, cacheFileName);
|
|
67
|
+
await mkdir(dirname(compiledPath), { recursive: true });
|
|
68
|
+
const { code } = await transform(source, {
|
|
69
|
+
loader,
|
|
70
|
+
format: 'esm',
|
|
71
|
+
target: 'es2021',
|
|
72
|
+
jsx: 'automatic',
|
|
73
|
+
sourcefile: sourceIdentifier,
|
|
74
|
+
sourcemap: 'inline',
|
|
75
|
+
});
|
|
76
|
+
await writeFile(compiledPath, code, 'utf8');
|
|
77
|
+
return compiledPath;
|
|
78
|
+
}
|
|
79
|
+
async loadTemplateFactoryFromContent(templateContent, templateKind) {
|
|
80
|
+
const moduleSource = this.buildContentModuleSource(templateContent);
|
|
81
|
+
const contentIdentifier = `inline-${templateKind}-template.tsx`;
|
|
82
|
+
const compiledPath = await this.compileSourceToCacheFile(moduleSource, contentIdentifier, 'tsx');
|
|
83
|
+
const templateModule = (await import(pathToFileURL(compiledPath).href));
|
|
84
|
+
const templateExport = templateModule.default ?? templateModule.render;
|
|
85
|
+
if (typeof templateExport !== 'function') {
|
|
86
|
+
throw new Error(`${templateKind} template content must export a function`);
|
|
87
|
+
}
|
|
88
|
+
return templateExport;
|
|
89
|
+
}
|
|
90
|
+
buildContentModuleSource(templateContent) {
|
|
91
|
+
const trimmedContent = templateContent.trim();
|
|
92
|
+
if (this.looksLikeTemplateModule(trimmedContent)) {
|
|
93
|
+
return trimmedContent;
|
|
94
|
+
}
|
|
95
|
+
const functionBody = this.looksLikeFunctionBody(trimmedContent)
|
|
96
|
+
? trimmedContent
|
|
97
|
+
: `return (${trimmedContent});`;
|
|
98
|
+
return [
|
|
99
|
+
'import React from "react";',
|
|
100
|
+
'export default function VintaSendTemplate(context) {',
|
|
101
|
+
functionBody,
|
|
102
|
+
'}',
|
|
103
|
+
].join('\n');
|
|
104
|
+
}
|
|
105
|
+
looksLikeTemplateModule(templateContent) {
|
|
106
|
+
return /\bexport\s+default\b|\bexport\s+(const|function)\s+render\b/.test(templateContent);
|
|
107
|
+
}
|
|
108
|
+
looksLikeFunctionBody(templateContent) {
|
|
109
|
+
return /(^|\n)\s*(return\b|if\b|for\b|while\b|switch\b|const\b|let\b|var\b|throw\b|try\b)/.test(templateContent);
|
|
110
|
+
}
|
|
111
|
+
getEsbuildLoader(templatePath) {
|
|
112
|
+
const extension = extname(templatePath).toLowerCase();
|
|
113
|
+
if (extension === '.tsx') {
|
|
114
|
+
return 'tsx';
|
|
115
|
+
}
|
|
116
|
+
return 'ts';
|
|
117
|
+
}
|
|
118
|
+
async renderBodyOutput(output) {
|
|
119
|
+
if (typeof output === 'string') {
|
|
120
|
+
return output;
|
|
121
|
+
}
|
|
122
|
+
return renderReactEmail(output);
|
|
123
|
+
}
|
|
124
|
+
renderSubjectOutput(output) {
|
|
125
|
+
if (typeof output === 'string') {
|
|
126
|
+
return output;
|
|
127
|
+
}
|
|
128
|
+
return String(output ?? '');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export class ReactEmailTemplateRendererFactory {
|
|
132
|
+
create() {
|
|
133
|
+
return new ReactEmailTemplateRenderer();
|
|
134
|
+
}
|
|
135
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vintasend-react-email",
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "VintaSend template renderer implementation for React Email",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"prepublishOnly": "npm run build",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"test:watch": "vitest",
|
|
11
|
+
"test:coverage": "vitest run --coverage"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"author": "Hugo Bessa",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@react-email/render": "^2.0.4",
|
|
20
|
+
"esbuild": "^0.25.11",
|
|
21
|
+
"react": "^19.2.0",
|
|
22
|
+
"react-dom": "^19.2.0",
|
|
23
|
+
"vintasend": "^0.9.1"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@vitest/coverage-v8": "4.0.18",
|
|
27
|
+
"typescript": "^5.9.3",
|
|
28
|
+
"vitest": "4.0.18"
|
|
29
|
+
}
|
|
30
|
+
}
|