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.
Files changed (65) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +127 -698
  3. package/dist/ai-server.js +116 -3
  4. package/dist/ai-server.js.map +1 -1
  5. package/dist/ai.js +507 -172
  6. package/dist/ai.js.map +1 -1
  7. package/dist/builder.js +8 -8
  8. package/dist/chunk-62HBUBBI.js +3866 -0
  9. package/dist/chunk-62HBUBBI.js.map +1 -0
  10. package/dist/{chunk-NC2YDZSP.js → chunk-6W3QUZR7.js} +8 -8
  11. package/dist/{chunk-W424KPCR.js → chunk-AXIM6E7T.js} +2 -2
  12. package/dist/{chunk-JU3OUO6X.js → chunk-DXWIVGHP.js} +3 -3
  13. package/dist/{chunk-QPAZLCPO.js → chunk-FAEVYSIF.js} +3 -3
  14. package/dist/{chunk-QPAZLCPO.js.map → chunk-FAEVYSIF.js.map} +1 -1
  15. package/dist/{chunk-4U7L2NYQ.js → chunk-G4U6PSF6.js} +2 -2
  16. package/dist/{chunk-5W4TUUPD.js → chunk-ITIG5NTJ.js} +4 -4
  17. package/dist/{chunk-CNJZLZRH.js → chunk-LUM7J5FF.js} +2 -2
  18. package/dist/{chunk-SAIW46IC.js → chunk-P7ABBLX6.js} +2 -2
  19. package/dist/chunk-SHHHYRPC.js +2829 -0
  20. package/dist/chunk-SHHHYRPC.js.map +1 -0
  21. package/dist/{chunk-XEFBPP5Y.js → chunk-WM2EEV4Q.js} +4 -4
  22. package/dist/index.js +10 -10
  23. package/dist/library.js +5 -5
  24. package/dist/server.js +8 -8
  25. package/dist/server.js.map +1 -1
  26. package/dist/styles.css +1 -1
  27. package/dist/types/ai/ConnectAi.d.ts +2 -1
  28. package/dist/types/ai/ModelPicker.d.ts +34 -0
  29. package/dist/types/ai/agent.d.ts +31 -0
  30. package/dist/types/ai/agentEndpoint.d.ts +69 -0
  31. package/dist/types/ai/claude.d.ts +2 -1
  32. package/dist/types/ai/draft.d.ts +10 -4
  33. package/dist/types/ai/generator.d.ts +10 -4
  34. package/dist/types/ai/i18n/de.d.ts +2 -0
  35. package/dist/types/ai/i18n/es.d.ts +2 -0
  36. package/dist/types/ai/i18n/pt.d.ts +2 -0
  37. package/dist/types/ai/i18n/ru.d.ts +2 -0
  38. package/dist/types/ai/i18n/zh.d.ts +2 -0
  39. package/dist/types/ai/index.d.ts +2 -1
  40. package/dist/types/ai/keys.d.ts +14 -1
  41. package/dist/types/ai/providers.d.ts +48 -5
  42. package/dist/types/ai/rewrite.d.ts +2 -1
  43. package/dist/types/ai/server.d.ts +3 -1
  44. package/dist/types/ai/subjects.d.ts +2 -1
  45. package/dist/types/core/constants.d.ts +1 -1
  46. package/dist/types/engine/NativeAdapter.d.ts +1 -1
  47. package/dist/types/i18n/de.d.ts +2 -0
  48. package/dist/types/i18n/es.d.ts +2 -0
  49. package/dist/types/i18n/pt.d.ts +2 -0
  50. package/dist/types/i18n/ru.d.ts +2 -0
  51. package/dist/types/i18n/zh.d.ts +2 -0
  52. package/dist/types/server/handler.d.ts +3 -0
  53. package/package.json +2 -2
  54. package/dist/chunk-JI36FNWE.js +0 -1361
  55. package/dist/chunk-JI36FNWE.js.map +0 -1
  56. package/dist/chunk-NZIQF7IW.js +0 -1830
  57. package/dist/chunk-NZIQF7IW.js.map +0 -1
  58. /package/dist/{chunk-NC2YDZSP.js.map → chunk-6W3QUZR7.js.map} +0 -0
  59. /package/dist/{chunk-W424KPCR.js.map → chunk-AXIM6E7T.js.map} +0 -0
  60. /package/dist/{chunk-JU3OUO6X.js.map → chunk-DXWIVGHP.js.map} +0 -0
  61. /package/dist/{chunk-4U7L2NYQ.js.map → chunk-G4U6PSF6.js.map} +0 -0
  62. /package/dist/{chunk-5W4TUUPD.js.map → chunk-ITIG5NTJ.js.map} +0 -0
  63. /package/dist/{chunk-CNJZLZRH.js.map → chunk-LUM7J5FF.js.map} +0 -0
  64. /package/dist/{chunk-SAIW46IC.js.map → chunk-P7ABBLX6.js.map} +0 -0
  65. /package/dist/{chunk-XEFBPP5Y.js.map → chunk-WM2EEV4Q.js.map} +0 -0
package/README.md CHANGED
@@ -1,246 +1,106 @@
1
- # Best Email Builder
2
-
3
- [![npm version](https://img.shields.io/npm/v/best-email-builder?color=cb3837&logo=npm)](https://www.npmjs.com/package/best-email-builder)
4
- [![npm downloads](https://img.shields.io/npm/dm/best-email-builder?color=cb3837)](https://www.npmjs.com/package/best-email-builder)
5
- [![CI](https://github.com/SergeMiro/best-email-builder/actions/workflows/ci.yml/badge.svg)](https://github.com/SergeMiro/best-email-builder/actions/workflows/ci.yml)
6
- [![Demo](https://img.shields.io/badge/demo-live-5b4fe0)](https://sergemiro.github.io/best-email-builder/)
7
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
8
-
9
- A drag-and-drop **email builder for React** that produces emails which actually
10
- render in Outlook, Gmail, Apple Mail and on mobile.
11
-
12
- **[Live demo](https://sergemiro.github.io/best-email-builder/)** — runs entirely in your
13
- browser (templates are kept in `localStorage`). ·
14
- **[npm](https://www.npmjs.com/package/best-email-builder)** — `npm install best-email-builder`
15
-
16
- ## Built for the whole developer community
17
-
18
- Every product that talks to its users ends up needing an email editor, and the
19
- choice is usually between a paid SaaS widget you cannot style or own, and a
20
- canvas you have to wire to MJML, storage, validation and sending yourself.
21
- Best Email Builder is the second option done once, properly, and given away:
22
-
23
- - **Free and MIT-licensed** — use it in commercial products, fork it, ship it.
24
- - **No account, no vendor.** It is a React component and a server handler
25
- you run in your own app, against your own database. The AI generation is
26
- optional and runs on your server, with your own key.
27
- - **Readable from end to end.** The engine is hidden behind one adapter, every
28
- block and feature is a registry entry, and the rules a template must obey
29
- live in one validation folder — so it is easy to change what you need.
30
- - **Open to contributions.** Bug reports, new blocks, new locales, storage
31
- recipes for your database and fixes for a mail client that renders something
32
- oddly are all welcome — see [Contributing](#contributing).
33
-
34
- - **MJML under the hood** — every block compiles to battle-tested, table-based
35
- email HTML. You never write a `<table>` by hand. Every template is checked
36
- against what Outlook (MSO tables, VML backgrounds), Gmail (102 KB clipping,
37
- dropped CSS) and Apple Mail need, and rendered at desktop and phone widths
38
- in the test suite.
39
- - **The blocks emails are made of**: heading, text, image, button, divider,
40
- spacer, social links, menu, 1 to 4 columns, image + text, and a hero banner
41
- with a background image — sections take a background image too.
42
- Columns take a share of the row (1/4 to 3/4), a section's background can
43
- span the whole screen, an image can take the full width on phones.
44
- - **Its own editor, end to end.** The interface, the canvas and the drag and
45
- drop are the builder's own; the email is a tree of MJML elements, and MJML
46
- alone turns it into HTML. The canvas shows the real compiled email — what you
47
- see is what Outlook, Gmail and Apple Mail receive. Templates are stored by
48
- *your* backend, as a versioned JSON project (the source of truth) plus
49
- server-compiled HTML.
50
- - **Editing that behaves**: drag a block, a line shows exactly where it will
51
- land; double-click a text to type in place, with bold, italic, underline,
52
- links; every element has its name, its toolbar (parent, move, duplicate,
53
- delete) and its keyboard shortcuts; an empty field of the panel shows the
54
- value MJML will really use.
55
- - **Server-side rendering only.** The browser never sends HTML: the server
56
- validates the project and the MJML, compiles, and stores its own HTML.
57
- - **An email written from scratch by AI** — the user picks what it is for
58
- among 68 purposes (sale, Christmas, password reset, meeting reminder, order
59
- shipped…), adds a line of details if they want, and a complete email opens in
60
- the editor, subject and preview text included, ready to edit and save. The
61
- model's MJML is cleaned and checked like any template before it gets there.
62
- OpenRouter, Gemini, Groq, Mistral, OpenAI, DeepSeek, Cerebras, Ollama or any
63
- OpenAI-compatible API by its key — free tiers included — and Claude through
64
- its SDK; on your server, or in the page with each user's own key — and the
65
- provider they choose.
66
- - **Any text rewritten in one click** — select a text or a button: *Shorter*,
67
- *Longer*, *Fix mistakes*, *Friendlier*, *More formal*, *Translate* into 13
68
- languages, or say what to change in your own words. The text changes in
69
- place, links and variables kept; Ctrl+Z brings the previous one back.
70
- - **Three subject lines on demand** — drawn from the words of the email, each
71
- from its own angle; a click puts one in the subject, your own stays in the
72
- list to come back to.
73
- - **The AI is optional** — a module of its own (`best-email-builder/ai`):
74
- without it, no AI button, and none of its code in your bundle.
75
- - **Your brand in every email** — give your website: the logo, colors and
76
- font are read from it (or set by hand), and new emails, templates and
77
- blocks start in them.
78
- - **A gallery of ready-made emails** — newsletter, welcome, promotion, event
79
- invitation… each one with the sender's footer and the unsubscribe link, so
80
- nobody starts from a blank page or forgets the legal mentions.
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
+ &nbsp;·&nbsp;
22
+ <a href="docs/README.md"><b>Documentation</b></a>
23
+ &nbsp;·&nbsp;
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
- Peer dependencies: `react` and `react-dom` ≥ 18.2. The server entry needs
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 { EmailBuilder, createHttpStorage } from 'best-email-builder';
91
+ import { EmailStudio, createHttpStorage } from 'best-email-builder';
117
92
  import 'best-email-builder/styles.css';
118
93
 
119
- const storage = createHttpStorage({ baseUrl: '/api/email-builder' });
120
-
121
- export default function TemplateEditor({ templateId, onClose }) {
94
+ export default function Emails() {
122
95
  return (
123
96
  <div style={{ height: '100vh' }}>
124
- <EmailBuilder
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 builder fills its parent: give the parent a height. It adapts to 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(), // replace with your database / object storage
257
- variableStore: createMemoryVariableRepository(), // optional: user-created variables
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
- Express or plain Node:
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
- Uploaded images are served **publicly** at `GET {basePath}/assets/<key>`
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
- ### 3. Sending a campaign
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
- | `{{contact.firstname}}`, `{{contact.lastname}}`, `{{contact.email}}` | Jane, Doe, jane.doe@example.com |
640
- | `{{company.name}}` | Example Inc. |
641
- | `{{campaign.name}}` | Newsletter |
642
- | `{{ unsubscribe }}` | the unsubscribe link (`unsubscribeUrl`) |
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
- ```bash
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
- See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the internals and
726
- [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
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** — one folder in `src/blocks/` and one line in its registry;
734
- - **a new interface language** — a dictionary like `src/i18n/fr.js`
735
- (`npm run check:i18n` lists the missing keys);
736
- - **a storage recipe** — the memory repositories are the reference shape for
737
- PostgreSQL, MongoDB, S3 and friends;
738
- - **a rendering report** — a template that looks wrong in a given mail client,
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
- Open an [issue](https://github.com/SergeMiro/best-email-builder/issues) to
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) — more
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).