best-email-builder 0.6.0 → 0.8.0
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/CHANGELOG.md +59 -0
- package/README.md +127 -698
- package/dist/ai-server.js +116 -3
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.js +507 -172
- package/dist/ai.js.map +1 -1
- package/dist/builder.js +8 -8
- package/dist/chunk-62HBUBBI.js +3866 -0
- package/dist/chunk-62HBUBBI.js.map +1 -0
- package/dist/{chunk-NC2YDZSP.js → chunk-6W3QUZR7.js} +8 -8
- package/dist/{chunk-W424KPCR.js → chunk-AXIM6E7T.js} +2 -2
- package/dist/{chunk-JU3OUO6X.js → chunk-DXWIVGHP.js} +3 -3
- package/dist/{chunk-QPAZLCPO.js → chunk-FAEVYSIF.js} +3 -3
- package/dist/{chunk-QPAZLCPO.js.map → chunk-FAEVYSIF.js.map} +1 -1
- package/dist/{chunk-4U7L2NYQ.js → chunk-G4U6PSF6.js} +2 -2
- package/dist/{chunk-5W4TUUPD.js → chunk-ITIG5NTJ.js} +4 -4
- package/dist/{chunk-CNJZLZRH.js → chunk-LUM7J5FF.js} +2 -2
- package/dist/{chunk-SAIW46IC.js → chunk-P7ABBLX6.js} +2 -2
- package/dist/chunk-SHHHYRPC.js +2829 -0
- package/dist/chunk-SHHHYRPC.js.map +1 -0
- package/dist/{chunk-XEFBPP5Y.js → chunk-WM2EEV4Q.js} +4 -4
- package/dist/index.js +10 -10
- package/dist/library.js +5 -5
- package/dist/server.js +8 -8
- package/dist/server.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/types/ai/ConnectAi.d.ts +2 -1
- package/dist/types/ai/ModelPicker.d.ts +34 -0
- package/dist/types/ai/agent.d.ts +31 -0
- package/dist/types/ai/agentEndpoint.d.ts +69 -0
- package/dist/types/ai/claude.d.ts +2 -1
- package/dist/types/ai/draft.d.ts +10 -4
- package/dist/types/ai/generator.d.ts +10 -4
- package/dist/types/ai/i18n/de.d.ts +2 -0
- package/dist/types/ai/i18n/es.d.ts +2 -0
- package/dist/types/ai/i18n/pt.d.ts +2 -0
- package/dist/types/ai/i18n/ru.d.ts +2 -0
- package/dist/types/ai/i18n/zh.d.ts +2 -0
- package/dist/types/ai/index.d.ts +2 -1
- package/dist/types/ai/keys.d.ts +14 -1
- package/dist/types/ai/providers.d.ts +48 -5
- package/dist/types/ai/rewrite.d.ts +2 -1
- package/dist/types/ai/server.d.ts +3 -1
- package/dist/types/ai/subjects.d.ts +2 -1
- package/dist/types/core/constants.d.ts +1 -1
- package/dist/types/engine/NativeAdapter.d.ts +1 -1
- package/dist/types/i18n/de.d.ts +2 -0
- package/dist/types/i18n/es.d.ts +2 -0
- package/dist/types/i18n/pt.d.ts +2 -0
- package/dist/types/i18n/ru.d.ts +2 -0
- package/dist/types/i18n/zh.d.ts +2 -0
- package/dist/types/server/handler.d.ts +3 -0
- package/package.json +2 -2
- package/dist/chunk-JI36FNWE.js +0 -1361
- package/dist/chunk-JI36FNWE.js.map +0 -1
- package/dist/chunk-NZIQF7IW.js +0 -1830
- package/dist/chunk-NZIQF7IW.js.map +0 -1
- /package/dist/{chunk-NC2YDZSP.js.map → chunk-6W3QUZR7.js.map} +0 -0
- /package/dist/{chunk-W424KPCR.js.map → chunk-AXIM6E7T.js.map} +0 -0
- /package/dist/{chunk-JU3OUO6X.js.map → chunk-DXWIVGHP.js.map} +0 -0
- /package/dist/{chunk-4U7L2NYQ.js.map → chunk-G4U6PSF6.js.map} +0 -0
- /package/dist/{chunk-5W4TUUPD.js.map → chunk-ITIG5NTJ.js.map} +0 -0
- /package/dist/{chunk-CNJZLZRH.js.map → chunk-LUM7J5FF.js.map} +0 -0
- /package/dist/{chunk-SAIW46IC.js.map → chunk-P7ABBLX6.js.map} +0 -0
- /package/dist/{chunk-XEFBPP5Y.js.map → chunk-WM2EEV4Q.js.map} +0 -0
package/README.md
CHANGED
|
@@ -1,246 +1,106 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
- **
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
- **Production features**: undo/redo, desktop/mobile canvas, autosave and local
|
|
82
|
-
drafts, versions, conflict detection between concurrent editors, image
|
|
83
|
-
uploads, server preview with quality checks (missing alt text, missing
|
|
84
|
-
unsubscribe link, unknown variables, Gmail clipping…) and a dark-mode view
|
|
85
|
-
(as the Gmail and Outlook apps repaint emails), test sends.
|
|
86
|
-
- **Merge-tag variables your users create themselves** — searchable menu
|
|
87
|
-
(Ctrl K, like every search of the builder),
|
|
88
|
-
insertion at the caret, in the subject line too, sample values for the
|
|
89
|
-
preview and fallbacks for recipients without a value.
|
|
90
|
-
- **Framework-agnostic server**: one Fetch-API handler for Next.js, Hono, Bun,
|
|
91
|
-
Deno, Cloudflare Workers — and Express / Node `http` through a bridge.
|
|
92
|
-
- **English and French** built in, any other language through a dictionary.
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/assets/logo.svg" width="96" height="72" alt="beb">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Best Email Builder</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
A drag-and-drop email builder for React.<br>
|
|
9
|
+
Emails that render in Outlook, Gmail, Apple Mail and on phones. Your server, your database, MIT-licensed.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://www.npmjs.com/package/best-email-builder"><img src="https://img.shields.io/npm/v/best-email-builder?color=cb3837&logo=npm" alt="npm version"></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/best-email-builder"><img src="https://img.shields.io/npm/dm/best-email-builder?color=cb3837" alt="npm downloads"></a>
|
|
15
|
+
<a href="https://github.com/SergeMiro/best-email-builder/actions/workflows/ci.yml"><img src="https://github.com/SergeMiro/best-email-builder/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
16
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License: MIT"></a>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<a href="https://sergemiro.github.io/best-email-builder/"><b>Live demo</b></a>
|
|
21
|
+
·
|
|
22
|
+
<a href="docs/README.md"><b>Documentation</b></a>
|
|
23
|
+
·
|
|
24
|
+
<a href="https://www.npmjs.com/package/best-email-builder"><b>npm</b></a>
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<a href="https://sergemiro.github.io/best-email-builder/"><img src="docs/assets/editor.png" width="900" alt="The editor: blocks on the left, the email in the middle, the selected button's properties on the right"></a>
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
## Why
|
|
32
|
+
|
|
33
|
+
Every product that talks to its users ends up needing an email editor. The
|
|
34
|
+
usual choice is a paid widget you cannot own, or a canvas you wire to MJML,
|
|
35
|
+
storage, validation and sending yourself. This is the second option, done
|
|
36
|
+
once and given away.
|
|
37
|
+
|
|
38
|
+
- **Free and yours.** MIT, no account, no vendor. A React component and a
|
|
39
|
+
server handler that run in your app, against your database.
|
|
40
|
+
- **Emails that hold up.** Every block compiles through MJML to table-based
|
|
41
|
+
HTML, checked against what Outlook, Gmail and Apple Mail need.
|
|
42
|
+
- **The server compiles.** The browser never sends HTML. The server validates
|
|
43
|
+
the template, compiles it and stores its own HTML.
|
|
44
|
+
- **Only what you use.** The library, the editor and the AI are separate
|
|
45
|
+
entry points, and one never pulls in the other's code.
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
**Editor**
|
|
50
|
+
- Content blocks (heading, text, image, button, divider, spacer, social
|
|
51
|
+
links, menu) and layouts (1 to 4 columns, image + text, hero banner).
|
|
52
|
+
- Columns from 1/4 to 3/4 of the row, background images on sections,
|
|
53
|
+
full-width images on phones.
|
|
54
|
+
- Drag with a drop line, type in place, a toolbar and shortcuts on every element.
|
|
55
|
+
- Desktop and mobile canvas, showing the real compiled email.
|
|
56
|
+
- Undo and redo, autosave, local drafts, versions, and conflict detection
|
|
57
|
+
between two editors.
|
|
58
|
+
|
|
59
|
+
**Email output**
|
|
60
|
+
- MJML under the hood: MSO tables and VML backgrounds for Outlook, the 102 KB
|
|
61
|
+
clipping limit for Gmail.
|
|
62
|
+
- A preview with quality checks (alt text, unsubscribe link, unknown
|
|
63
|
+
variables, Gmail clipping) and a dark-mode view.
|
|
64
|
+
- Test sends, and a gallery of ready-made emails with a footer and an
|
|
65
|
+
unsubscribe link.
|
|
66
|
+
|
|
67
|
+
**AI, optional**
|
|
68
|
+
- *Generate* a complete email from one of 68 purposes.
|
|
69
|
+
- *Rewrite* any text: shorter, longer, fixed, friendlier, more formal, or
|
|
70
|
+
translated into 13 languages.
|
|
71
|
+
- *Suggest* three subject lines drawn from the email.
|
|
72
|
+
- OpenRouter, Gemini, Groq, Mistral, OpenAI, DeepSeek, Cerebras, Ollama,
|
|
73
|
+
Claude or your own agent. The model runs on your server, or in the page
|
|
74
|
+
with each user's own key.
|
|
75
|
+
|
|
76
|
+
**Your app**
|
|
77
|
+
- Merge-tag variables, including ones your users create.
|
|
78
|
+
- Your brand read from your website: logo, colors and font.
|
|
79
|
+
- One Fetch-API handler for Next.js, Hono, Bun, Deno, Cloudflare Workers and Express.
|
|
80
|
+
- Seven interface languages, and any other through a dictionary.
|
|
93
81
|
|
|
94
|
-
|
|
95
|
-
React UI ─► EmailEditor ─► NativeAdapter ─► the email: an MJML tree
|
|
96
|
-
(ui/) (core/) (engine/) │ canvas = the email compiled
|
|
97
|
-
│ by mjml-browser
|
|
98
|
-
│ project JSON + MJML
|
|
99
|
-
Server: validation ─► MJML compilation ─► variables ─► links ─► final HTML
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
## Install
|
|
82
|
+
## Quick start
|
|
103
83
|
|
|
104
84
|
```bash
|
|
105
85
|
npm install best-email-builder
|
|
106
86
|
```
|
|
107
87
|
|
|
108
|
-
|
|
109
|
-
Node ≥ 20 (or any runtime with the Fetch API and `node:crypto`).
|
|
110
|
-
|
|
111
|
-
## Quick start
|
|
112
|
-
|
|
113
|
-
### 1. The component
|
|
88
|
+
The component: the template library and the editor, wired together.
|
|
114
89
|
|
|
115
90
|
```jsx
|
|
116
|
-
import {
|
|
91
|
+
import { EmailStudio, createHttpStorage } from 'best-email-builder';
|
|
117
92
|
import 'best-email-builder/styles.css';
|
|
118
93
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
export default function TemplateEditor({ templateId, onClose }) {
|
|
94
|
+
export default function Emails() {
|
|
122
95
|
return (
|
|
123
96
|
<div style={{ height: '100vh' }}>
|
|
124
|
-
<
|
|
125
|
-
storage={storage}
|
|
126
|
-
templateId={templateId} // omit to create a new template
|
|
127
|
-
onClose={onClose}
|
|
128
|
-
onSaved={(template) => console.log('saved', template.id)}
|
|
129
|
-
/>
|
|
97
|
+
<EmailStudio storage={createHttpStorage()} />
|
|
130
98
|
</div>
|
|
131
99
|
);
|
|
132
100
|
}
|
|
133
101
|
```
|
|
134
102
|
|
|
135
|
-
The
|
|
136
|
-
width it is given — not the window's: three columns from 1180 px, properties
|
|
137
|
-
in a drawer below that, and on a phone the canvas alone with *Add a block* /
|
|
138
|
-
*Edit* drawers. It is a client-only
|
|
139
|
-
component (the canvas needs the DOM). With Next.js, load it with
|
|
140
|
-
`dynamic(() => import(...), { ssr: false })` or render it from a
|
|
141
|
-
`'use client'` component.
|
|
142
|
-
|
|
143
|
-
### Two blocks, each one on its own
|
|
144
|
-
|
|
145
|
-
The package has two blocks. An application installs one, the other, or both,
|
|
146
|
-
and each has its own entry point, so one never brings the other's code:
|
|
147
|
-
|
|
148
|
-
| Block | Import | What it is |
|
|
149
|
-
|---|---|---|
|
|
150
|
-
| A — the library | `import { TemplateLibrary } from 'best-email-builder/library'` | Your templates and the ready-made bases in a searchable list, the selected one's details, its rendered preview (desktop / mobile, light / dark). *Edit*, *New template* and *Start from this base* only call `onEdit(template)` / `onCreate(start)` — it never mounts an editor, and loads neither the editor's engine nor its MJML compiler. |
|
|
151
|
-
| B — the editor | `import { EmailBuilder } from 'best-email-builder/builder'` | The editor (above). Opens whatever the host hands it; knows nothing of the library. |
|
|
152
|
-
| A + B | `import { EmailStudio } from 'best-email-builder'` | The two wired together: the library, then the editor, then back to the library with the template just saved selected. The editor has no *Templates* button there — the bases are in the library. |
|
|
153
|
-
|
|
154
|
-
`best-email-builder` (the main entry) still exports everything; the two
|
|
155
|
-
sub-entries are for an application that wants one block only. Both use the
|
|
156
|
-
same stylesheet, `best-email-builder/styles.css`.
|
|
157
|
-
|
|
158
|
-
#### A + B, nothing to wire
|
|
159
|
-
|
|
160
|
-
```jsx
|
|
161
|
-
import { EmailStudio, createHttpStorage } from 'best-email-builder';
|
|
162
|
-
|
|
163
|
-
<EmailStudio storage={createHttpStorage()} locale="fr" sender={sender} />
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
#### Block B with your own library
|
|
167
|
-
|
|
168
|
-
Your library (a table, a sidebar, a modal — anything) only decides what the
|
|
169
|
-
editor opens, and hears back when it saves or closes:
|
|
170
|
-
|
|
171
|
-
| Your library wants to… | Pass to `EmailBuilder` |
|
|
172
|
-
|---|---|
|
|
173
|
-
| open a saved template | `templateId={id}` (the editor loads it from `storage`) |
|
|
174
|
-
| start from content it has (a base, an import, a copy) | `initialMjml={mjml}` and, optionally, `initialMeta={{ subject, preheader }}` |
|
|
175
|
-
| start from a blank email | neither |
|
|
176
|
-
| refresh its list after a save | `onSaved={(template) => …}` — `template.id` is set from the first save on |
|
|
177
|
-
| show itself again | `onClose={() => …}` (the *Back* button) |
|
|
178
|
-
| hide the editor's own gallery of bases | `templates={[]}` |
|
|
179
|
-
|
|
180
|
-
Give the editor a `key` that changes with what it opens, so switching from one
|
|
181
|
-
template to another starts a fresh editor.
|
|
182
|
-
|
|
183
|
-
```jsx
|
|
184
|
-
import { useState } from 'react';
|
|
185
|
-
import { EmailBuilder, createHttpStorage } from 'best-email-builder/builder';
|
|
186
|
-
|
|
187
|
-
const storage = createHttpStorage();
|
|
188
|
-
|
|
189
|
-
function Emails() {
|
|
190
|
-
const [open, setOpen] = useState(null); // null = my library
|
|
191
|
-
|
|
192
|
-
if (!open) {
|
|
193
|
-
return (
|
|
194
|
-
<MyTemplateList // your block A
|
|
195
|
-
onOpen={(id) => setOpen({ key: id, templateId: id })}
|
|
196
|
-
onNew={() => setOpen({ key: `new-${Date.now()}` })}
|
|
197
|
-
/>
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
return (
|
|
201
|
-
<EmailBuilder
|
|
202
|
-
key={open.key}
|
|
203
|
-
storage={storage}
|
|
204
|
-
locale="fr"
|
|
205
|
-
templates={[]}
|
|
206
|
-
templateId={open.templateId ?? null}
|
|
207
|
-
initialMjml={open.mjml}
|
|
208
|
-
initialMeta={open.meta}
|
|
209
|
-
onSaved={() => refreshMyList()}
|
|
210
|
-
onClose={() => setOpen(null)}
|
|
211
|
-
/>
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
Your list can read the same templates with `storage.list()` (summaries, newest
|
|
217
|
-
first) or from your own API — the editor only needs the `id`.
|
|
218
|
-
|
|
219
|
-
#### Block A with your own editor
|
|
220
|
-
|
|
221
|
-
```jsx
|
|
222
|
-
import { TemplateLibrary, createHttpStorage } from 'best-email-builder/library';
|
|
223
|
-
|
|
224
|
-
<TemplateLibrary
|
|
225
|
-
storage={createHttpStorage()}
|
|
226
|
-
locale="fr"
|
|
227
|
-
onEdit={(template) => router.push(`/emails/${template.id}`)}
|
|
228
|
-
onCreate={(start) => openMyEditor(start)} // start: null (new), or { baseId, mjml, meta: { subject, preheader } }
|
|
229
|
-
onClose={closeModal} // optional: shows a close button
|
|
230
|
-
/>
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
The library needs `storage.list()`; *Delete* appears when the storage has
|
|
234
|
-
`remove()`, the preview uses `render()` (else the HTML compiled at the last
|
|
235
|
-
save). Its props: `storage`, `locale`, `messages`, `onNotify`, `sender`,
|
|
236
|
-
`templates` (the bases, `[]` hides them), `title`, `description`,
|
|
237
|
-
`selectedId`, `favoritesKey` (the star of the details moves an entry to a
|
|
238
|
-
*Favorites* section at the top; kept in `localStorage` under this key, `null`
|
|
239
|
-
turns favorites off), `onEdit`, `onCreate`, `onClose`, `className`. Like the
|
|
240
|
-
builder, it fills its parent and follows its own width (three columns, then
|
|
241
|
-
two, then list and details as two screens).
|
|
242
|
-
|
|
243
|
-
### 2. The server routes
|
|
103
|
+
The server: every route it calls, in one handler.
|
|
244
104
|
|
|
245
105
|
```js
|
|
246
106
|
// app/api/email-builder/[...path]/route.js (Next.js App Router)
|
|
@@ -248,505 +108,74 @@ import {
|
|
|
248
108
|
createEmailBuilderHandler,
|
|
249
109
|
createMemoryTemplateRepository,
|
|
250
110
|
createMemoryAssetRepository,
|
|
251
|
-
createMemoryVariableRepository,
|
|
252
111
|
} from 'best-email-builder/server';
|
|
253
112
|
|
|
254
113
|
const handler = createEmailBuilderHandler({
|
|
255
114
|
templates: createMemoryTemplateRepository(), // replace with your database
|
|
256
|
-
assets: createMemoryAssetRepository(),
|
|
257
|
-
|
|
258
|
-
authorize: async (request, { action }) => {
|
|
259
|
-
const user = await getUserFromSession(request); // your auth
|
|
260
|
-
return user ? { id: user.id, name: user.email } : null; // null → 401
|
|
261
|
-
},
|
|
262
|
-
publicUrl: process.env.PUBLIC_URL, // absolute image URLs in sent emails
|
|
263
|
-
transport: { // optional: enables "Send test"
|
|
264
|
-
send: ({ to, subject, html, text }) => mailer.sendMail({ from: 'no-reply@example.com', to, subject, html, text }),
|
|
265
|
-
},
|
|
115
|
+
assets: createMemoryAssetRepository(),
|
|
116
|
+
authorize: async (request) => getUserFromSession(request), // your auth; null → 401
|
|
266
117
|
});
|
|
267
118
|
|
|
268
119
|
export { handler as GET, handler as POST, handler as PUT, handler as DELETE };
|
|
269
120
|
```
|
|
270
121
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
```js
|
|
274
|
-
import express from 'express';
|
|
275
|
-
import { createEmailBuilderHandler, toNodeListener } from 'best-email-builder/server';
|
|
276
|
-
|
|
277
|
-
const app = express();
|
|
278
|
-
app.use(toNodeListener(createEmailBuilderHandler({ /* … */ })));
|
|
279
|
-
```
|
|
122
|
+
That is a working builder. [Getting started](docs/GETTING-STARTED.md) covers
|
|
123
|
+
the layout, Next.js, Express and image hosting.
|
|
280
124
|
|
|
281
|
-
|
|
282
|
-
(mail clients download them without a session). Make sure that path is not
|
|
283
|
-
behind your authentication middleware.
|
|
125
|
+
## Pick what you need
|
|
284
126
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
The builder stores the compiled HTML; at send time, personalize it per
|
|
288
|
-
recipient:
|
|
289
|
-
|
|
290
|
-
```js
|
|
291
|
-
import { personalize, applyVariablesToText } from 'best-email-builder/server';
|
|
292
|
-
|
|
293
|
-
const variables = await handler.catalog(); // system + user-created variables
|
|
294
|
-
const values = { 'contact.firstname': 'Ada', 'contact.email': 'ada@example.com' };
|
|
295
|
-
const html = personalize(template.html, {
|
|
296
|
-
values,
|
|
297
|
-
variables,
|
|
298
|
-
unsubscribeUrl: signedUnsubscribeUrl(recipient),
|
|
299
|
-
baseUrl: 'https://app.example.com', // makes uploaded images absolute
|
|
300
|
-
});
|
|
301
|
-
const subject = applyVariablesToText(template.subject, values, variables);
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
With an email provider that does its own merge, map the tags instead:
|
|
305
|
-
`mapVariables(html, { 'contact.firstname': '{{ contact.FIRSTNAME }}' })`.
|
|
306
|
-
|
|
307
|
-
### 4. AI (an optional module)
|
|
308
|
-
|
|
309
|
-
The AI is a module of its own, `best-email-builder/ai`. An application that
|
|
310
|
-
does not import it has no AI button anywhere, and none of the AI's code or
|
|
311
|
-
texts in its bundle. Given to the builder, it brings three things:
|
|
312
|
-
|
|
313
|
-
- **Generate** (toolbar) — asks one thing: what the email is for (a badge
|
|
314
|
-
among 68, grouped: sales, holidays, events, news, account, orders,
|
|
315
|
-
relationship, organization), with an optional line of details. The email is
|
|
316
|
-
written in the interface's language, with the builder's sender and
|
|
317
|
-
variables, and replaces the one being edited — the dialog says so before,
|
|
318
|
-
not after.
|
|
319
|
-
- **Rewrite with AI** (properties of a text or a button) and **Suggest
|
|
320
|
-
subjects** (under the subject), below.
|
|
321
|
-
|
|
322
|
-
```jsx
|
|
323
|
-
import { createAi } from 'best-email-builder/ai';
|
|
324
|
-
|
|
325
|
-
<EmailBuilder storage={storage} ai={createAi(/* where the model runs, below */)} />
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
Pass `generate: false`, `rewrite: false` or `suggestSubjects: false` to leave
|
|
329
|
-
a part out (its button with it), or functions of your own for any of them.
|
|
330
|
-
|
|
331
|
-
#### Where the model runs
|
|
332
|
-
|
|
333
|
-
**On your server** — the key stays there; the right choice for production:
|
|
334
|
-
|
|
335
|
-
```js
|
|
336
|
-
// server
|
|
337
|
-
import { createEmailBuilderHandler, compileMjml } from 'best-email-builder/server';
|
|
338
|
-
import { createEmailGenerator, providerEmailModel } from 'best-email-builder/ai/server';
|
|
339
|
-
|
|
340
|
-
const handler = createEmailBuilderHandler({
|
|
341
|
-
/* … */
|
|
342
|
-
generator: createEmailGenerator({
|
|
343
|
-
model: providerEmailModel({ provider: 'openrouter', apiKey: process.env.OPENROUTER_API_KEY }),
|
|
344
|
-
compile: compileMjml, // MJML's own warnings go back to the model
|
|
345
|
-
}),
|
|
346
|
-
rateLimits: { generatePerHour: 30, rewritePerHour: 200, subjectsPerHour: 100 }, // per user (defaults)
|
|
347
|
-
});
|
|
348
|
-
```
|
|
349
|
-
|
|
350
|
-
```jsx
|
|
351
|
-
// browser: the handler's /generate, /rewrite and /subjects routes
|
|
352
|
-
<EmailBuilder storage={createHttpStorage()} ai={createAi()} />
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
`createAi({ baseUrl, headers })` points at the same routes elsewhere, or with
|
|
356
|
-
the user's credentials. Without a `generator`, those routes answer 501.
|
|
357
|
-
|
|
358
|
-
**In the page, with each user's own key** — no server needed: the first AI
|
|
359
|
-
action opens a *Connect* dialog (the provider, a link to get a key, what is
|
|
360
|
-
free), the key is kept in that browser and sent only to the provider. A key
|
|
361
|
-
the provider refuses is forgotten and asked again. Off by default:
|
|
362
|
-
|
|
363
|
-
```jsx
|
|
364
|
-
const ai = createAi({ provider: 'openrouter', askKey: true });
|
|
365
|
-
```
|
|
366
|
-
|
|
367
|
-
Give a list and each user chooses: the dialog shows the providers as cards
|
|
368
|
-
(name, model, free or not, a dot on those whose key this browser keeps). The
|
|
369
|
-
choice and one key per provider are remembered; going back to a provider whose
|
|
370
|
-
key is kept needs nothing pasted.
|
|
371
|
-
|
|
372
|
-
```jsx
|
|
373
|
-
const ai = createAi({ provider: ['openai', 'mistral', 'gemini', 'openrouter'], askKey: true });
|
|
374
|
-
```
|
|
375
|
-
|
|
376
|
-
`ai.key` reads and changes it: `provider` (the one in use), `providers`,
|
|
377
|
-
`choose(id)`, `get(id?)`, `set(key)`, `forget()`, `request()` (opens the
|
|
378
|
-
dialog — to change provider, for one), `subscribe(fn)`, `state()`
|
|
379
|
-
(`{ asking, refused, connected }`). This is what the live demo does.
|
|
380
|
-
|
|
381
|
-
**In the page, with one key for everyone** — prototypes and internal tools
|
|
382
|
-
only, the key is in the page's code: `createAi({ provider: 'groq', apiKey })`.
|
|
383
|
-
|
|
384
|
-
#### Providers
|
|
385
|
-
|
|
386
|
-
Every provider here speaks the OpenAI *chat completions* API; nothing to
|
|
387
|
-
install. `provider` is an id below, or `{ id, model, baseUrl, headers, maxTokens, body }`
|
|
388
|
-
to change the model or reach an endpoint of your own. `AI_PROVIDERS` lists them
|
|
389
|
-
(name, address, default model, where to get a key, what is free).
|
|
390
|
-
|
|
391
|
-
| `provider` | Default model | Free to start | Key |
|
|
392
|
-
|---|---|---|---|
|
|
393
|
-
| `'openrouter'` | `openrouter/free` (a router over the free models of the day) | free models, with a free account | [openrouter.ai/keys](https://openrouter.ai/keys) |
|
|
394
|
-
| `'gemini'` | `gemini-3.5-flash-lite` | free tier, rate-limited | [aistudio.google.com/apikey](https://aistudio.google.com/apikey) |
|
|
395
|
-
| `'groq'` | `openai/gpt-oss-120b` | free tier, rate-limited | [console.groq.com/keys](https://console.groq.com/keys) |
|
|
396
|
-
| `'cerebras'` | `gpt-oss-120b` | free tier, rate-limited | [cloud.cerebras.ai](https://cloud.cerebras.ai/) |
|
|
397
|
-
| `'mistral'` | `mistral-small-latest` | free *Experiment* plan | [console.mistral.ai/api-keys](https://console.mistral.ai/api-keys) |
|
|
398
|
-
| `'openai'` | `gpt-5-mini` | — | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) |
|
|
399
|
-
| `'deepseek'` | `deepseek-chat` | — | [platform.deepseek.com/api_keys](https://platform.deepseek.com/api_keys) |
|
|
400
|
-
| `'ollama'` | — (give `model`) | runs on your machine, no key | — |
|
|
401
|
-
| `'openai-compatible'` | — (give `baseUrl` and `model`) | LM Studio, vLLM, Together, a proxy of your own… | as it needs |
|
|
402
|
-
|
|
403
|
-
```js
|
|
404
|
-
providerEmailModel({ provider: { id: 'groq', model: 'llama-4-scout' }, apiKey });
|
|
405
|
-
providerEmailModel({ provider: { baseUrl: 'http://localhost:1234/v1', model: 'qwen3' } });
|
|
406
|
-
```
|
|
407
|
-
|
|
408
|
-
- Model names change often: the defaults were current at this release —
|
|
409
|
-
pass `model` to be sure of yours.
|
|
410
|
-
- `openrouter/free` costs nothing and is slow: most free models reason before
|
|
411
|
-
they write, so an email takes two to six minutes, and some days a free
|
|
412
|
-
model is busy upstream. OpenRouter is asked for less reasoning
|
|
413
|
-
(`reasoning: { effort: 'low' }`) and given room for it (`maxTokens: 32000`;
|
|
414
|
-
16 000 elsewhere). For drafts in seconds, use a free tier with a key of its
|
|
415
|
-
own (Gemini, Groq, Cerebras) or a paid model.
|
|
416
|
-
- `body` adds fields of your own to each request — OpenRouter's fallback list,
|
|
417
|
-
for one: `providerEmailModel({ provider: 'openrouter', apiKey, body: { models: [...] } })`.
|
|
418
|
-
It cannot change the model, the messages or the output limit.
|
|
419
|
-
- The answer is asked as structured output (a JSON schema); a model that
|
|
420
|
-
refuses it is asked for a JSON object, then for plain JSON — once, then
|
|
421
|
-
remembered. A model whose structured output breaks the JSON (some do on the
|
|
422
|
-
quotes of HTML) can be told to skip it: `provider: { …, format: 'text' }`.
|
|
423
|
-
- The provider's failures become errors the interface explains, in its
|
|
424
|
-
language: `KEY_MISSING`, `KEY_REFUSED`, `MODEL_CREDITS`, `MODEL_BUSY`,
|
|
425
|
-
`MODEL_ERROR` (with the provider's own text in `detail`). An exhausted
|
|
426
|
-
quota is `MODEL_CREDITS`, not `MODEL_BUSY`, even when it comes as a 429
|
|
427
|
-
(OpenAI's `insufficient_quota`) or a 400 (Anthropic's credit balance);
|
|
428
|
-
`failureCode(status, message)` is that rule, for a model of your own. None is a 401:
|
|
429
|
-
the key is the AI's, not the user's session. A 403 about the model (not
|
|
430
|
-
available here, in this region) or the content is not `KEY_REFUSED`: with
|
|
431
|
-
`askKey`, the user's key is not thrown away for it.
|
|
432
|
-
- **Claude**: `claudeEmailModel({ client: new Anthropic() })` with the
|
|
433
|
-
Anthropic SDK (`npm install @anthropic-ai/sdk`) — `claude-opus-5-5` by
|
|
434
|
-
default, `effort: 'medium'`, structured output, streamed so a long email
|
|
435
|
-
does not hit a request timeout — or through OpenRouter with an
|
|
436
|
-
`anthropic/…` model and one key for every model. With `askKey`, Claude is
|
|
437
|
-
one more provider to choose, with the user's own Anthropic key and the SDK
|
|
438
|
-
loaded only when it is chosen:
|
|
439
|
-
|
|
440
|
-
```js
|
|
441
|
-
import { claudeProvider, createAi } from 'best-email-builder/ai';
|
|
442
|
-
|
|
443
|
-
const claude = claudeProvider({
|
|
444
|
-
client: async (apiKey) => {
|
|
445
|
-
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
446
|
-
return new Anthropic({ apiKey, dangerouslyAllowBrowser: true });
|
|
447
|
-
},
|
|
448
|
-
});
|
|
449
|
-
createAi({ provider: ['openai', claude, 'gemini'], askKey: true });
|
|
450
|
-
```
|
|
451
|
-
- **Anything else** is a function `({ system, messages, schema, signal, task })
|
|
452
|
-
=> Promise<string>` returning the JSON text — `createEmailGenerator({ model })`
|
|
453
|
-
on the server, `createAi({ model })` in the page.
|
|
454
|
-
|
|
455
|
-
A generation takes from 20 seconds to a minute or two: on a server, give the
|
|
456
|
-
route that long (`maxDuration` on Vercel, the proxy's timeout).
|
|
457
|
-
|
|
458
|
-
What comes out is never trusted as is: shorthand padding is expanded,
|
|
459
|
-
elements the builder does not edit (`mj-hero`, `mj-wrapper`, `mj-head`…) are
|
|
460
|
-
turned into sections and columns, stock photos are removed (the user picks
|
|
461
|
-
real images; the alt text stays), unknown variables become visible
|
|
462
|
-
`[placeholders]`, and the result must pass the same validation as a saved
|
|
463
|
-
template. When a problem remains, the model gets one more turn with the list.
|
|
464
|
-
The prompt (`SYSTEM_PROMPT`, `buildUserPrompt`) and the purposes
|
|
465
|
-
(`EMAIL_PURPOSES`) are exported from `best-email-builder/ai/server`.
|
|
466
|
-
|
|
467
|
-
**Rewrite with AI.** The same generator rewrites one text: with it, selecting
|
|
468
|
-
a text block or a button shows a *Rewrite with AI* section in its properties —
|
|
469
|
-
*Shorter*, *Longer*, *Fix mistakes*, *Friendlier*, *More formal*, *Translate*
|
|
470
|
-
(13 languages, under their own names) and a field to say what to change. The
|
|
471
|
-
answer replaces the text at once, in one step of the history: Ctrl+Z brings
|
|
472
|
-
the previous text back, so there is nothing to accept or compare. Selecting
|
|
473
|
-
another element cancels a rewrite in progress; if the text was edited
|
|
474
|
-
meanwhile, the user's words win and the answer is dropped.
|
|
475
|
-
|
|
476
|
-
- On a server it is `POST {base}/rewrite`; a generator without `rewrite`
|
|
477
|
-
answers 501.
|
|
478
|
-
- Every link (the same `href`, none added) and every variable (the same keys,
|
|
479
|
-
none added; a fallback after `|` may be translated) must survive, and the
|
|
480
|
-
HTML must pass the validator — otherwise the model gets one more turn, then
|
|
481
|
-
nothing is changed. A button label comes back as plain words.
|
|
482
|
-
- `claudeEmailModel` asks a rewrite for `rewriteEffort: 'low'` (a few seconds);
|
|
483
|
-
a model of your own gets `task: 'rewrite'` in its request to tell the two apart.
|
|
484
|
-
- `REWRITE_ACTIONS`, `REWRITE_LANGUAGES` and `REWRITE_SYSTEM_PROMPT` are
|
|
485
|
-
exported.
|
|
486
|
-
|
|
487
|
-
**Suggest subjects.** Under the subject of the email, *Suggest subjects* asks
|
|
488
|
-
for three other subject lines, drawn from the words of the email, each from
|
|
489
|
-
its own angle (clear, curious, personal or timely). They come as a list to
|
|
490
|
-
pick from, with the user's own subject first: a click puts one in the field,
|
|
491
|
-
another click another one, the first row brings the user's subject back.
|
|
492
|
-
*More ideas* asks for three new ones, *Done* closes the list.
|
|
493
|
-
|
|
494
|
-
- On a server it is `POST {base}/subjects` (`generator.suggestSubjects`);
|
|
495
|
-
100 per user per hour.
|
|
496
|
-
- The subjects are plain lines, different from the current subject and from
|
|
497
|
-
each other, in the language of the email; a merge tag is allowed only from
|
|
498
|
-
the server's catalog. Otherwise the model gets one more turn; fewer than
|
|
499
|
-
three good ones are still shown.
|
|
500
|
-
- `claudeEmailModel` uses `rewriteEffort` for them as well (`task: 'subjects'`).
|
|
501
|
-
`emailWords(mjml)` (the words a reader sees) and `SUBJECTS_SYSTEM_PROMPT`
|
|
502
|
-
are exported.
|
|
503
|
-
|
|
504
|
-
The live demo has no server: there, the AI runs in the page with the
|
|
505
|
-
visitor's own key, for the provider they choose — OpenAI, Claude, Mistral,
|
|
506
|
-
Gemini or OpenRouter (`createAi({ provider: ['openai', claude, 'mistral', 'gemini', 'openrouter'], askKey: true })`).
|
|
507
|
-
|
|
508
|
-
### 5. Your brand (optional)
|
|
509
|
-
|
|
510
|
-
The *Brand* button opens one dialog. Its first field is the company's
|
|
511
|
-
website: *Get my brand* reads its logo, main color, headings color, font and
|
|
512
|
-
name, and fills the fields below. Everything can then be changed by hand
|
|
513
|
-
(upload a logo, pick a color), and a small preview follows every change.
|
|
514
|
-
*Save* keeps the brand for the whole workspace:
|
|
515
|
-
|
|
516
|
-
- the email being edited is repainted (the box *Also apply to this email* is
|
|
517
|
-
ticked by default) — one step of the history, Ctrl+Z brings the previous
|
|
518
|
-
look back;
|
|
519
|
-
- new emails, gallery templates, inserted blocks and AI-generated emails
|
|
520
|
-
start in the brand: the logo (or the name) at the top, the name in the
|
|
521
|
-
footer, buttons and banners in the main color, headings in their color.
|
|
522
|
-
|
|
523
|
-
Emails are repainted by **role**, not by exact color, so any email works —
|
|
524
|
-
a template, a generated one, one built by hand: filled buttons and colored
|
|
525
|
-
banners take the main color, and their text switches to white or dark
|
|
526
|
-
whichever reads; bold headings take the headings color; colored links take
|
|
527
|
-
the main color (or the headings color when the main one is too light to read
|
|
528
|
-
on white); gray footers and body text keep theirs.
|
|
529
|
-
|
|
530
|
-
```js
|
|
531
|
-
import {
|
|
532
|
-
createBrandImporter,
|
|
533
|
-
createMemoryBrandRepository,
|
|
534
|
-
} from 'best-email-builder/server';
|
|
535
|
-
|
|
536
|
-
const handler = createEmailBuilderHandler({
|
|
537
|
-
// …
|
|
538
|
-
brandStore: createMemoryBrandRepository(), // one row: { get(), save(brand) }
|
|
539
|
-
brandImporter: createBrandImporter(), // optional: "Get my brand"
|
|
540
|
-
rateLimits: { brandImportsPerHour: 20 }, // per user (default)
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
// In the browser:
|
|
544
|
-
const storage = createHttpStorage({ brand: true }); // { brand: { import: false } } without an importer
|
|
545
|
-
```
|
|
546
|
-
|
|
547
|
-
- Routes: `GET {base}/brand`, `PUT {base}/brand` (write permission),
|
|
548
|
-
`POST {base}/brand/import` with `{ url }` (write permission). The import
|
|
549
|
-
saves nothing: the user reviews the result first. A logo found on the site
|
|
550
|
-
is stored like an upload (PNG, JPEG or GIF; SVG and WebP logos are skipped,
|
|
551
|
-
since most email apps do not show them — the dialog says so).
|
|
552
|
-
- Without `brandImporter` the dialog has no website field; without
|
|
553
|
-
`brandStore` there is no *Brand* button.
|
|
554
|
-
- Some websites refuse automated readers (bot protection answers 403): the
|
|
555
|
-
dialog says so, and the brand is filled in by hand.
|
|
556
|
-
- `applyBrand(mjml, brand)` and `cleanBrand(brand)` are exported for your
|
|
557
|
-
own pipeline (e.g. rebranding stored templates).
|
|
558
|
-
|
|
559
|
-
The live demo keeps the brand in `localStorage` and has no website field
|
|
560
|
-
(reading a website needs a server).
|
|
561
|
-
|
|
562
|
-
## Component props
|
|
563
|
-
|
|
564
|
-
| Prop | Default | Description |
|
|
565
|
-
|---|---|---|
|
|
566
|
-
| `storage` | `createHttpStorage()` | Where templates and images go (see *Storage*). |
|
|
567
|
-
| `templateId` | `null` | Template to open; `null` starts from the starter template (or a local draft). |
|
|
568
|
-
| `locale` | `'en'` | `'en'`, `'fr'`, or any BCP 47 tag used with `messages`. |
|
|
569
|
-
| `messages` | — | Extra or overriding translations (`{ 'Save': 'Speichern', … }`). |
|
|
570
|
-
| `onNotify` | built-in notices | `(level, message) => void` to use your own toaster. |
|
|
571
|
-
| `variables` | `DEFAULT_VARIABLES` | Merge tags offered in the *Variables* menu. Pass the same list to the server. |
|
|
572
|
-
| `sender` | — | `{ name, address, logoUrl, logoWidth }` used by the starter template footer. |
|
|
573
|
-
| `starterMjml` | — | Replace the starter template entirely. |
|
|
574
|
-
| `templates` | `TEMPLATE_GALLERY` | Ready-made emails offered by the *Templates* button (blank, simple, newsletter, welcome, promotion, event invitation). Pass your own `{ id, name, description, create({ sender, t }), meta?(t) }` entries, or `[]` to hide the gallery. |
|
|
575
|
-
| `initialMjml`, `initialMeta` | — | Open a NEW template on given MJML and metadata (`{ name, subject, preheader, description }`) — e.g. a template converted from another format. Ignored with `templateId`. |
|
|
576
|
-
| `testRecipients` | `[]` | Addresses pre-filled in the *Send test* dialog. |
|
|
577
|
-
| `onClose` | — | Shows a *Back* button; called after confirming unsaved changes. |
|
|
578
|
-
| `onSaved` | — | Called with the saved template. |
|
|
579
|
-
| `onDirtyChange` | — | Called when the unsaved state changes. |
|
|
580
|
-
| `dirtyCheckRef` | — | Ref receiving a function `() => boolean` (synchronous dirty check). |
|
|
581
|
-
| `ai` | — | The AI module, `createAi()` from `best-email-builder/ai` (see *AI*). Without it, no AI button and no AI code. |
|
|
582
|
-
|
|
583
|
-
## Storage
|
|
584
|
-
|
|
585
|
-
The component only knows this contract — implement it to talk to any backend,
|
|
586
|
-
or use `createHttpStorage()` with the server handler:
|
|
587
|
-
|
|
588
|
-
```ts
|
|
589
|
-
interface TemplateStorage {
|
|
590
|
-
list?(): Promise<Template[]>; // TemplateLibrary: newest first, no document/MJML
|
|
591
|
-
load(id): Promise<Template>;
|
|
592
|
-
remove?(id): Promise<void>; // TemplateLibrary: enables Delete
|
|
593
|
-
create(payload): Promise<{ template; warnings }>;
|
|
594
|
-
duplicate(id): Promise<{ template; warnings }>;
|
|
595
|
-
save(id, payload): Promise<{ template; warnings; version }>;
|
|
596
|
-
listVersions(id): Promise<Version[]>;
|
|
597
|
-
getVersion(id, versionId): Promise<Version & { document; mjml }>;
|
|
598
|
-
uploadImage(file): Promise<{ path }>;
|
|
599
|
-
render?(input): Promise<{ html; warnings; quality }>; // enables Preview
|
|
600
|
-
sendTest?(input): Promise<{ sent; failed; invalid; results }>; // enables Send test
|
|
601
|
-
listVariables?(): Promise<Variable[]>; // enable user-created variables
|
|
602
|
-
saveVariable?(variable, { create }): Promise<Variable>;
|
|
603
|
-
deleteVariable?(key): Promise<void>;
|
|
604
|
-
loadBrand?(): Promise<Brand>; // enable the Brand button
|
|
605
|
-
saveBrand?(brand): Promise<Brand>;
|
|
606
|
-
importBrand?(url, { signal }): Promise<{ brand; notes }>; // enables "Get my brand"
|
|
607
|
-
}
|
|
608
|
-
```
|
|
609
|
-
|
|
610
|
-
`createMemoryStorage()` implements it in memory (demos, tests, Storybook-like
|
|
611
|
-
playgrounds).
|
|
612
|
-
|
|
613
|
-
## Server repositories
|
|
614
|
-
|
|
615
|
-
The handler applies the business rules (validation, compilation, versions,
|
|
616
|
-
concurrency); persistence is delegated to two small repositories. The memory
|
|
617
|
-
implementations (`createMemoryTemplateRepository`, `createMemoryAssetRepository`)
|
|
618
|
-
are the reference: copy their shape for SQL, MongoDB, S3…
|
|
619
|
-
|
|
620
|
-
- `templates.update(id, fields, { expectedRevision })` must be **atomic**
|
|
621
|
-
(`UPDATE … WHERE id = ? AND revision = ?`) and return `{ conflict: true, current }`
|
|
622
|
-
on mismatch — this is what prevents two editors from overwriting each other.
|
|
623
|
-
- `templates.addVersion(templateId, version, { keep })` numbers versions and
|
|
624
|
-
keeps the latest `keep` (50 by default).
|
|
625
|
-
- `assets.findBySha256 / insert / get` — images are deduplicated by content hash.
|
|
626
|
-
|
|
627
|
-
The full contracts are documented as JSDoc types in
|
|
628
|
-
[`src/server/templates.js`](src/server/templates.js) and
|
|
629
|
-
[`src/server/assets.js`](src/server/assets.js).
|
|
630
|
-
|
|
631
|
-
## Variables
|
|
632
|
-
|
|
633
|
-
The editor only inserts tags; values are filled on the server.
|
|
634
|
-
|
|
635
|
-
**System variables** come from your application (read-only in the interface):
|
|
636
|
-
|
|
637
|
-
| Tag | Sample |
|
|
127
|
+
| You want | Import |
|
|
638
128
|
|---|---|
|
|
639
|
-
|
|
|
640
|
-
| `
|
|
641
|
-
| `
|
|
642
|
-
|
|
|
643
|
-
|
|
644
|
-
Define your own list with `defineVariables([...])` and pass it to both
|
|
645
|
-
`<EmailBuilder variables>` and `createEmailBuilderHandler({ variables })`.
|
|
646
|
-
|
|
647
|
-
**Custom variables** are created by users from the *Variables* menu
|
|
648
|
-
(*New variable*): a name ("Order number"), a tag proposed from it
|
|
649
|
-
(`{{order_number}}`, frozen once created since templates contain it), a group,
|
|
650
|
-
a **sample value** used by the preview and test sends, and a **value if empty**
|
|
651
|
-
for recipients without one. They are shared by every template and stored by the
|
|
652
|
-
`variableStore` repository (`createMemoryVariableRepository` is the reference).
|
|
653
|
-
|
|
654
|
-
Using them:
|
|
655
|
-
|
|
656
|
-
- the menu has a search field (↑/↓ and Enter work), and a search without
|
|
657
|
-
result offers to create the variable under that name;
|
|
658
|
-
- a click inserts the tag at the caret of the text being edited; the
|
|
659
|
-
unsubscribe link becomes the URL of a selected button;
|
|
660
|
-
- the `{ }` buttons next to *Subject* and *Preview text* insert a tag there;
|
|
661
|
-
- `{{contact.firstname|there}}` sets an inline fallback for one place;
|
|
662
|
-
- the preview flags unknown tags (`{{contact.fistname}}`) before they reach anyone.
|
|
663
|
-
|
|
664
|
-
Values are HTML-escaped. A missing value falls back to the inline fallback,
|
|
665
|
-
then to the variable's *value if empty*, then to an empty string — a raw tag
|
|
666
|
-
never reaches a recipient.
|
|
667
|
-
|
|
668
|
-
## Theming
|
|
669
|
-
|
|
670
|
-
All colours, radii and fonts are CSS custom properties prefixed `--eb-`
|
|
671
|
-
(see [`src/styles.css`](src/styles.css)). Override them on `:root`:
|
|
672
|
-
|
|
673
|
-
```css
|
|
674
|
-
:root {
|
|
675
|
-
--eb-primary: #0f766e;
|
|
676
|
-
--eb-primary-foreground: #fff;
|
|
677
|
-
--eb-font-sans: 'Inter', sans-serif;
|
|
678
|
-
}
|
|
679
|
-
```
|
|
680
|
-
|
|
681
|
-
A dark theme applies under a `.dark` ancestor. The stylesheet uses Tailwind
|
|
682
|
-
CSS v4 with an `eb:` prefix and a reset scoped to the builder: it does not
|
|
683
|
-
touch the rest of your page, whether or not you use Tailwind yourself.
|
|
684
|
-
|
|
685
|
-
## Security model
|
|
686
|
-
|
|
687
|
-
- The server never stores HTML from the browser: it compiles its own.
|
|
688
|
-
- MJML and projects are validated against an allow-list (no scripts, event
|
|
689
|
-
handlers, `javascript:` URLs, `mj-include`, conditional comments…).
|
|
690
|
-
- Uploaded images are type-checked from their bytes (PNG, JPEG, GIF; SVG is
|
|
691
|
-
refused), limited to 2 MB, keyed by content hash and served with
|
|
692
|
-
`nosniff` and a sandboxing CSP.
|
|
693
|
-
- Previews are rendered in an iframe with an empty `sandbox`.
|
|
694
|
-
- Test sends are limited (10 addresses, 20 sends per user per hour by default).
|
|
695
|
-
- Reading a brand from a website is a request your server makes to an address
|
|
696
|
-
a user typed, so it is fenced: http(s) on ports 80 and 443 only, no
|
|
697
|
-
credentials in the address, and every address the name resolves to must be
|
|
698
|
-
public — loopback, private networks, link-local and cloud metadata
|
|
699
|
-
(`169.254.169.254`), carrier-grade NAT, multicast and IPv4 hidden in IPv6
|
|
700
|
-
(mapped, NAT64, 6to4) are refused, at the lookup itself so a DNS answer
|
|
701
|
-
cannot slip through. Redirects (4 at most) are checked the same way. Pages,
|
|
702
|
-
stylesheets and logos have size limits after decompression, the whole
|
|
703
|
-
reading 8 seconds, and imports are limited per user (20 per hour).
|
|
704
|
-
- Generated emails pass the same MJML validation as saved ones; generation is
|
|
705
|
-
limited per user (30 per hour by default) and needs the `write` permission.
|
|
706
|
-
A rewritten text keeps its links and variables (none added) and passes the
|
|
707
|
-
same validation; rewrites are limited too (200 per user per hour), and so
|
|
708
|
-
are subject ideas (100), whose merge tags come from the server's catalog.
|
|
709
|
-
|
|
710
|
-
## Development
|
|
129
|
+
| The library and the editor, wired together | `EmailStudio` from `best-email-builder` |
|
|
130
|
+
| The editor only | `EmailBuilder` from `best-email-builder/builder` |
|
|
131
|
+
| The library only | `TemplateLibrary` from `best-email-builder/library` |
|
|
132
|
+
| The server routes | `createEmailBuilderHandler` from `best-email-builder/server` |
|
|
133
|
+
| The AI | `createAi` from `best-email-builder/ai`, `createEmailGenerator` from `best-email-builder/ai/server` |
|
|
711
134
|
|
|
712
|
-
|
|
713
|
-
npm install
|
|
714
|
-
npm run dev # Ladle playground on http://localhost:61010
|
|
715
|
-
npm test # unit tests (node:test)
|
|
716
|
-
npm run lint
|
|
717
|
-
npm run typecheck
|
|
718
|
-
npm run test:stories # Playwright against the playground
|
|
719
|
-
npm run example # full example app (React + server)
|
|
720
|
-
npm run demo # static demo (the GitHub Pages one)
|
|
721
|
-
npm run test:e2e # Playwright against the example app
|
|
722
|
-
npm run build # dist/ (ESM + styles.css + types)
|
|
723
|
-
```
|
|
135
|
+
## Documentation
|
|
724
136
|
|
|
725
|
-
|
|
726
|
-
|
|
137
|
+
| Page | What it covers |
|
|
138
|
+
|---|---|
|
|
139
|
+
| [Getting started](docs/GETTING-STARTED.md) | Install, the component, the server routes. |
|
|
140
|
+
| [Editor and library](docs/EDITOR.md) | The three ways to mount them, every prop, theming. |
|
|
141
|
+
| [Server](docs/SERVER.md) | Routes, handler options, the storage contract, your database. |
|
|
142
|
+
| [Variables and sending](docs/VARIABLES.md) | Merge tags, custom variables, personalizing at send time. |
|
|
143
|
+
| [AI](docs/AI.md) | Generate, Rewrite, Subjects. Where the model runs, and the providers. |
|
|
144
|
+
| [Your own AI agent](docs/AGENT.md) | The `agent@1` protocol. |
|
|
145
|
+
| [Brand](docs/BRAND.md) | Logo, colors and font, read from a website. |
|
|
146
|
+
| [Languages](docs/LANGUAGES.md) | Seven built in, your own strings, adding one. |
|
|
147
|
+
| [Security model](docs/SECURITY-MODEL.md) | What the server accepts and refuses. |
|
|
148
|
+
| [Architecture](docs/ARCHITECTURE.md) | The source tree and the rules of the code. |
|
|
149
|
+
|
|
150
|
+
## Languages
|
|
151
|
+
|
|
152
|
+
English, French, German, Spanish, Portuguese (Brazil), Russian and Chinese
|
|
153
|
+
are built in: `<EmailStudio locale="de" />`. Any other language works through
|
|
154
|
+
`messages`. See [Languages](docs/LANGUAGES.md).
|
|
727
155
|
|
|
728
156
|
## Contributing
|
|
729
157
|
|
|
730
158
|
This project is built for and with the developer community. Good first
|
|
731
159
|
contributions:
|
|
732
160
|
|
|
733
|
-
- **a new block
|
|
734
|
-
- **a new interface language
|
|
735
|
-
(
|
|
736
|
-
- **a storage recipe
|
|
737
|
-
PostgreSQL, MongoDB, S3 and
|
|
738
|
-
- **a rendering report
|
|
739
|
-
with a screenshot and the client version.
|
|
161
|
+
- **a new block**: one folder in `src/blocks/` and one line in its registry;
|
|
162
|
+
- **a new interface language**: two dictionaries
|
|
163
|
+
(see [Languages](docs/LANGUAGES.md#adding-a-language-to-the-package));
|
|
164
|
+
- **a storage recipe**: the memory repositories are the reference shape for
|
|
165
|
+
PostgreSQL, MongoDB, S3 and so on;
|
|
166
|
+
- **a rendering report**: a template that looks wrong in a given mail client,
|
|
167
|
+
with a screenshot and the client's version.
|
|
740
168
|
|
|
741
|
-
|
|
169
|
+
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request, and
|
|
170
|
+
open an [issue](https://github.com/SergeMiro/best-email-builder/issues) to
|
|
742
171
|
discuss a larger change first. Security problems go through the private
|
|
743
172
|
channel described in [SECURITY.md](SECURITY.md).
|
|
744
173
|
|
|
745
174
|
## License
|
|
746
175
|
|
|
747
|
-
[MIT](LICENSE). MJML and mjml-browser are MIT.
|
|
176
|
+
[MIT](LICENSE). MJML and mjml-browser are MIT too.
|
|
748
177
|
|
|
749
178
|
## Author
|
|
750
179
|
|
|
751
|
-
Built and maintained by [Sergiy Miro](https://smiro.dev/projects)
|
|
180
|
+
Built and maintained by [Sergiy Miro](https://smiro.dev/projects). More
|
|
752
181
|
open-source work and case studies on [smiro.dev](https://smiro.dev/projects).
|