best-email-builder 0.3.0 → 0.5.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 (112) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +290 -20
  3. package/dist/ai-server.js +68 -0
  4. package/dist/ai-server.js.map +1 -0
  5. package/dist/ai.js +852 -0
  6. package/dist/ai.js.map +1 -0
  7. package/dist/builder.js +16 -8
  8. package/dist/chunk-2RZHGAH5.js +64 -0
  9. package/dist/chunk-2RZHGAH5.js.map +1 -0
  10. package/dist/chunk-3LKF4QBL.js +595 -0
  11. package/dist/chunk-3LKF4QBL.js.map +1 -0
  12. package/dist/chunk-5SASKCUC.js +251 -0
  13. package/dist/chunk-5SASKCUC.js.map +1 -0
  14. package/dist/{chunk-JOC752I3.js → chunk-6D3SLT5W.js} +27 -29
  15. package/dist/chunk-6D3SLT5W.js.map +1 -0
  16. package/dist/chunk-7U6BAKM7.js +119 -0
  17. package/dist/chunk-7U6BAKM7.js.map +1 -0
  18. package/dist/chunk-DFNHICTF.js +1359 -0
  19. package/dist/chunk-DFNHICTF.js.map +1 -0
  20. package/dist/chunk-ECHGJZ2W.js +327 -0
  21. package/dist/chunk-ECHGJZ2W.js.map +1 -0
  22. package/dist/chunk-KXJJSVN5.js +98 -0
  23. package/dist/chunk-KXJJSVN5.js.map +1 -0
  24. package/dist/{chunk-ZSUHOCCE.js → chunk-LXR4QTGP.js} +123 -579
  25. package/dist/chunk-LXR4QTGP.js.map +1 -0
  26. package/dist/{chunk-BVOVAEEJ.js → chunk-LYMJ2TBX.js} +2 -2
  27. package/dist/chunk-MPX5CWTZ.js +1687 -0
  28. package/dist/chunk-MPX5CWTZ.js.map +1 -0
  29. package/dist/chunk-PB7YZ3WP.js +5635 -0
  30. package/dist/chunk-PB7YZ3WP.js.map +1 -0
  31. package/dist/chunk-UFKCLU2D.js +255 -0
  32. package/dist/chunk-UFKCLU2D.js.map +1 -0
  33. package/dist/index.js +51 -20
  34. package/dist/index.js.map +1 -1
  35. package/dist/library.js +12 -7
  36. package/dist/server.js +513 -214
  37. package/dist/server.js.map +1 -1
  38. package/dist/styles.css +1 -1
  39. package/dist/types/ai/ConnectAi.d.ts +12 -0
  40. package/dist/types/ai/GenerateButton.d.ts +4 -0
  41. package/dist/types/ai/RewriteText.d.ts +5 -0
  42. package/dist/types/ai/SubjectIdeas.d.ts +25 -0
  43. package/dist/types/ai/chip.d.ts +1 -0
  44. package/dist/types/ai/claude.d.ts +21 -0
  45. package/dist/types/ai/createAi.d.ts +66 -0
  46. package/dist/types/ai/draft.d.ts +163 -0
  47. package/dist/types/ai/generator.d.ts +123 -0
  48. package/dist/types/ai/i18n/fr.d.ts +2 -0
  49. package/dist/types/ai/i18n/index.d.ts +2 -0
  50. package/dist/types/ai/index.d.ts +9 -0
  51. package/dist/types/ai/keys.d.ts +46 -0
  52. package/dist/types/ai/prompt.d.ts +76 -0
  53. package/dist/types/ai/providers.d.ts +67 -0
  54. package/dist/types/ai/purposes.d.ts +48 -0
  55. package/dist/types/ai/rewrite.d.ts +83 -0
  56. package/dist/types/ai/server.d.ts +9 -0
  57. package/dist/types/ai/subjects.d.ts +97 -0
  58. package/dist/types/blocks/shared.d.ts +20 -0
  59. package/dist/types/core/EmailEditor.d.ts +72 -19
  60. package/dist/types/core/constants.d.ts +1 -1
  61. package/dist/types/core/mjmlDefaults.d.ts +13 -0
  62. package/dist/types/core/scrollbars.d.ts +1 -1
  63. package/dist/types/defaultEditor.d.ts +4 -1
  64. package/dist/types/engine/NativeAdapter.d.ts +28 -0
  65. package/dist/types/engine/canvas/CanvasView.d.ts +88 -0
  66. package/dist/types/engine/canvas/canvasStyles.d.ts +3 -0
  67. package/dist/types/engine/canvas/icons.d.ts +14 -0
  68. package/dist/types/engine/canvas/morph.d.ts +21 -0
  69. package/dist/types/engine/canvasMjml.d.ts +30 -0
  70. package/dist/types/engine/mjml.d.ts +31 -0
  71. package/dist/types/engine/model.d.ts +130 -0
  72. package/dist/types/engine/placement.d.ts +69 -0
  73. package/dist/types/engine/richText.d.ts +15 -0
  74. package/dist/types/engine/unwrapModule.d.ts +29 -0
  75. package/dist/types/features/brand/BrandButton.d.ts +4 -0
  76. package/dist/types/features/brand/brand.d.ts +108 -0
  77. package/dist/types/features/brand/extract.d.ts +61 -0
  78. package/dist/types/features/brand/index.d.ts +2 -0
  79. package/dist/types/features/brand/messages.d.ts +13 -0
  80. package/dist/types/features/brand/useBrand.d.ts +22 -0
  81. package/dist/types/features/preview/PreviewButton.d.ts +1 -1
  82. package/dist/types/features/preview/darkMode.d.ts +65 -0
  83. package/dist/types/features/variables/VariablePicker.d.ts +3 -2
  84. package/dist/types/i18n/index.d.ts +8 -0
  85. package/dist/types/index.d.ts +5 -2
  86. package/dist/types/server/brand.d.ts +32 -0
  87. package/dist/types/server/brandImport.d.ts +93 -0
  88. package/dist/types/server/compileMjml.d.ts +2 -0
  89. package/dist/types/server/handler.d.ts +26 -0
  90. package/dist/types/server/index.d.ts +5 -1
  91. package/dist/types/storage/client.d.ts +57 -3
  92. package/dist/types/ui/EmailBuilder.d.ts +5 -1
  93. package/dist/types/ui/EmailStudio.d.ts +6 -1
  94. package/dist/types/ui/PropertiesPanel.d.ts +2 -1
  95. package/dist/types/ui/fields.d.ts +2 -1
  96. package/dist/types/ui/primitives/button.d.ts +1 -1
  97. package/dist/types/ui/primitives/color-picker.d.ts +2 -1
  98. package/dist/types/ui/primitives/searchShortcut.d.ts +16 -0
  99. package/dist/types/ui/useLayoutWidth.d.ts +1 -1
  100. package/package.json +27 -7
  101. package/dist/chunk-3M36VVXS.js +0 -665
  102. package/dist/chunk-3M36VVXS.js.map +0 -1
  103. package/dist/chunk-57CTQNTK.js +0 -56
  104. package/dist/chunk-57CTQNTK.js.map +0 -1
  105. package/dist/chunk-ILRAQTHS.js +0 -3677
  106. package/dist/chunk-ILRAQTHS.js.map +0 -1
  107. package/dist/chunk-JOC752I3.js.map +0 -1
  108. package/dist/chunk-ZSUHOCCE.js.map +0 -1
  109. package/dist/types/adapters/grapesjs/GrapesJsAdapter.d.ts +0 -33
  110. package/dist/types/adapters/grapesjs/engineConfig.d.ts +0 -148
  111. package/dist/types/adapters/grapesjs/unwrapModule.d.ts +0 -18
  112. /package/dist/{chunk-BVOVAEEJ.js.map → chunk-LYMJ2TBX.js.map} +0 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,196 @@ All notable changes are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.5.0] — 2026-10-15
10
+
11
+ ### Changed — the AI is a module (breaking)
12
+ - Everything AI moved to its own entries: `best-email-builder/ai` (the
13
+ builder's side) and `best-email-builder/ai/server` (the generator, the
14
+ prompts, `claudeEmailModel`). An application that does not import them has
15
+ no AI button anywhere, and none of the AI's code or texts in its bundle.
16
+ - The builder takes the module as a prop: `<EmailBuilder ai={createAi()} />`
17
+ (`EmailStudio` passes it on). `createAi()` calls the handler's routes;
18
+ `createAi({ baseUrl, headers })` other ones; `generate`, `rewrite` and
19
+ `suggestSubjects` can each be `false` (no button) or a function of your own.
20
+ - Removed: `createHttpStorage({ generate: true })` and the storage's
21
+ `generate`, `rewrite` and `suggestSubjects`; `createEmailGenerator`,
22
+ `claudeEmailModel` and the prompts are no longer exported by
23
+ `best-email-builder/server` — import them from `best-email-builder/ai/server`.
24
+ The handler still takes the generator as `generator`.
25
+
26
+ ### Added
27
+ - **AI providers by their key**, nothing to install: `providerEmailModel({ provider, apiKey })`
28
+ and `createAi({ provider, … })` for OpenRouter (default `openrouter/free`,
29
+ free models), Google Gemini, Groq, Cerebras, Mistral, OpenAI, DeepSeek,
30
+ Ollama and any OpenAI-compatible API (`{ baseUrl, model }`). Structured
31
+ output where the model takes it, stepped down to a JSON object, then plain
32
+ JSON, once. `AI_PROVIDERS` lists them with their default model, where to get
33
+ a key and what is free. New errors, translated: `KEY_MISSING`,
34
+ `KEY_REFUSED`, `MODEL_CREDITS`, `MODEL_BUSY`, `MODEL_ERROR`.
35
+ - **Each user's own key**: `createAi({ provider, askKey: true })` (off by
36
+ default) — the first AI action opens a *Connect* dialog (the provider, a
37
+ link to get a key, what is free, the key hidden or shown), the key is kept
38
+ in that browser and sent only to the provider; a refused key is forgotten
39
+ and asked again in the same action. `ai.key` reads and changes it.
40
+ - **Ctrl K** (⌘K on Apple) puts the cursor in the search of *Generate* and of
41
+ the variables menu, as in the template library; each search shows the hint.
42
+ - The live demo runs the AI with the visitor's own OpenRouter key, asked in
43
+ the *Connect* dialog instead of the browser's prompt; its code samples have
44
+ a *With AI* tab.
45
+
46
+ ### Fixed
47
+ - Dark-mode preview: text and lines are now decided against the background
48
+ they really sit on (nested tables, `bgcolor`, CSS), by contrast; legacy
49
+ attributes (`<font color>`, `<body text link>`, `bgcolor` without `#`),
50
+ `hsl()` colors and per-rule CSS are handled; comments, Outlook-only blocks
51
+ and the doctype are left alone; the dark sheet goes in the real `<head>`.
52
+ - *Suggest subjects*: a subject quoting itself, a list introduced by a line of
53
+ prose, entities in the email's words, a merge tag of another tool
54
+ (`*|FNAME|*`, `%name%`), more than 90 characters, capitals the email does not
55
+ use, "!!" or the preheader repeated are no longer offered; the language
56
+ falls back to the server's. In the list, what the user types becomes
57
+ "yours", another email loaded closes it, "More ideas" avoids every idea
58
+ already shown and keeps the chosen one, the arrows move through it, and a
59
+ cancelled request can no longer end the one after it.
60
+ - MJML a model escaped twice (`\"`, `\n` left in the markup — some free
61
+ models do) is read as MJML instead of failing the draft or the rewrite.
62
+ - Types: `createEmailGenerator()`'s `rewrite` and `suggestSubjects` named
63
+ input types the declaration file did not import (a TypeScript error without
64
+ `skipLibCheck`).
65
+
66
+ ## [0.4.0] — 2026-10-15
67
+
68
+ ### Changed — the builder's own engine (breaking)
69
+ - **GrapesJS is gone.** The interface, the canvas, the drag and drop and the
70
+ text editing are the builder's own (`src/engine/`); the email is a tree of
71
+ MJML elements, and MJML alone turns it into HTML. The canvas shows the email
72
+ compiled by `mjml-browser` 5.4.1 (the same MJML version as the server): what
73
+ is seen is what is sent. The package lost GrapesJS and grapesjs-mjml from its
74
+ dependencies, and its stylesheet their CSS.
75
+ - Stored documents are written by the engine `beb-mjml` (project: the MJML
76
+ tree). **Templates saved before** (engine `grapesjs-mjml`) open from their
77
+ stored MJML, versions included, and are saved in the new format next time;
78
+ a local draft of the old format is dropped.
79
+ - `editor.exportHtml()` returns a Promise (MJML 5 compiles asynchronously).
80
+ - `createGrapesJsAdapter` is replaced by `createNativeAdapter`; `parseMjml`,
81
+ `parseMjmlFragment` and `serializeMjml` are exported.
82
+ - `createEmailEditor({ …, t })` takes the interface's translator: the texts a
83
+ block brings and the names in the canvas follow the language. A block's
84
+ `mjml` / `sectionMjml` may be a function of `t`.
85
+
86
+ ### Added
87
+ - **Generate an email with AI.** A *Generate* button opens a dialog with 68
88
+ purposes as badges in 8 groups (sales, holidays, events, news, account,
89
+ orders, relationship, organization), a search field and an optional line of
90
+ details. The email is written in the interface's language with the
91
+ builder's sender and variables, cleaned for the builder (longhand padding,
92
+ sections and columns only, no stock photos, unknown variables as visible
93
+ placeholders), validated like a saved template, and opens in the editor with
94
+ its subject and preview text. Server: `createEmailGenerator({ model })`,
95
+ `claudeEmailModel({ client })` (Claude Opus 5.5, structured output,
96
+ streamed), the `generator` option of the handler (`POST /generate`, 30 per
97
+ user per hour); client: `createHttpStorage({ generate: true })`. Any other
98
+ provider plugs in as a function. The live demo generates with the visitor's
99
+ own Anthropic key.
100
+ - **Rewrite with AI.** Selecting a text or a button shows, in its properties,
101
+ *Shorter*, *Longer*, *Fix mistakes*, *Friendlier*, *More formal*,
102
+ *Translate* (13 languages) and a field to say what to change. The text is
103
+ replaced in place, in one step of the history (Ctrl+Z). Links and variables
104
+ must survive and the HTML must pass the validator, or the model gets one more
105
+ turn and then nothing changes; a button label stays plain text. Server:
106
+ `generator.rewrite` (`createEmailGenerator` returns it), `POST /rewrite`
107
+ (200 per user per hour, `rateLimits.rewritePerHour`), `claudeEmailModel`'s
108
+ `rewriteEffort` (`'low'`); client: `storage.rewrite`, given by
109
+ `createHttpStorage({ generate: true })`. `editor.getElement(id).html` returns
110
+ the stored HTML of a text block.
111
+ - **Your brand.** A *Brand* button opens one dialog: the company's website
112
+ first (*Get my brand* reads its logo, main color, headings color, font and
113
+ name), then every field by hand, with a live preview. Saving keeps the
114
+ brand for the workspace and repaints the email being edited in one step
115
+ (Ctrl+Z); new emails, gallery templates, inserted blocks and generated
116
+ emails start in it. Emails are repainted by role (filled buttons and
117
+ colored banners, bold headings, colored links, fonts; gray footers and body
118
+ text kept), and the text on a colored background switches to what reads.
119
+ Server: `brandStore` (`createMemoryBrandRepository`), `brandImporter`
120
+ (`createBrandImporter`: public http(s) addresses on ports 80/443 only,
121
+ private, link-local, metadata and IPv4-in-IPv6 addresses refused at the
122
+ DNS lookup and on every redirect, size and time limits), routes
123
+ `GET/PUT /brand` and `POST /brand/import` (20 per user per hour,
124
+ `rateLimits.brandImportsPerHour`); client: `createHttpStorage({ brand: true })`,
125
+ `storage.loadBrand / saveBrand / importBrand`. `applyBrand`, `cleanBrand`,
126
+ `brandedSender`, `isPublicAddress`, `editor.setBrand` and `editor.setMjml`
127
+ (replace the email in one undoable step) are exported. The live demo keeps
128
+ the brand in `localStorage`.
129
+ - **Suggest subjects.** Under the subject, one click gives three other
130
+ subject lines drawn from the words of the email, each from its own angle.
131
+ They are a list to pick from, the user's own subject first to come back to;
132
+ *More ideas* asks for three new ones. Plain lines, distinct, in the email's
133
+ language, merge tags only from the catalog (one repair turn, fewer than
134
+ three still shown). Server: `generator.suggestSubjects`, `POST /subjects`
135
+ (100 per user per hour, `rateLimits.subjectsPerHour`); client:
136
+ `storage.suggestSubjects`, given by `createHttpStorage({ generate: true })`.
137
+ `emailWords` and `SUBJECTS_SYSTEM_PROMPT` are exported.
138
+ - **Dark mode in the preview.** A sun / moon switch next to Desktop / Mobile
139
+ shows the email as the apps that repaint it in dark mode do (Gmail and
140
+ Outlook apps, Outlook.com): light backgrounds and borders turn dark of the
141
+ same hue, dark text turns light, brand colors, light text and images stay —
142
+ a dark logo on a transparent background disappears there as it does in
143
+ those apps. Simulated on the compiled HTML, without script: the frame stays
144
+ sandboxed. In the editor's preview and in the template library.
145
+ `simulateDarkMode(html)` is exported.
146
+ - Drag and drop with pointer events: a line shows where the block lands
147
+ (between two elements of a column, or between two sections — a content block
148
+ then gets a section of its own), the canvas scrolls near its edges, Esc
149
+ cancels. Sections and elements move by the ✥ handle of their toolbar.
150
+ - On the selection: its name, and a toolbar — select the parent, move,
151
+ duplicate, delete. Keyboard: Delete, Ctrl+Z / Ctrl+Y, Ctrl+C / Ctrl+V,
152
+ Ctrl+D, Escape (parent), Enter (edit the text).
153
+ - Ctrl+A selects every block of the email (each section outlined, "All
154
+ blocks" in the panel); Delete — or the panel's delete button — removes them
155
+ in one step, Ctrl+Z brings them back. Inside a text being edited or a field,
156
+ Ctrl+A keeps selecting the text. `editor.selectAll()` does the same.
157
+ - The toolbar's first button names where it goes ("⤴ Column", "⤴ 2 columns"):
158
+ it selects the parent element; a bare up arrow read as "move up".
159
+ - Panel features for well-made emails: **column widths** (1/4 to 3/4; the
160
+ other columns of the section share the rest, "Equal" resets), a section
161
+ **background across the whole screen**, an image **full width on mobile**,
162
+ menu links **as typed or in capitals** (MJML capitalised them; new menus
163
+ are written as typed).
164
+ - The server's HTML gets `x-apple-disable-message-reformatting` and
165
+ `format-detection` (Apple Mail does not rescale the email, iOS does not turn
166
+ dates, addresses and phone numbers into blue links).
167
+ - Email-client checks on the compiled HTML of every template and of an email
168
+ holding every block (tests/unit/email-clients.test.mjs): Outlook's MSO
169
+ tables and VML backgrounds, Gmail's 102 KB clipping and dropped CSS, images
170
+ (alt, width, no border), mobile media queries and the fallback without
171
+ `<style>`, links, and the same body from the canvas compiler and the
172
+ server's. And rendered in a browser at 640 px, 375 px and without `<style>`
173
+ (e2e/rendering.stories.spec.js).
174
+ - The template library is called a library everywhere ("Bibliothèque de
175
+ modèles e-mail" in French, instead of "Banque").
176
+ - Text edited in place (double-click, or a click on a selected text) with a
177
+ formatting bar: bold, italic, underline, strike, link, unlink, clear. Pasted
178
+ content arrives as plain text, Enter is a line break, the HTML is reduced to
179
+ what an email accepts.
180
+ - The panel shows MJML's own default in an empty field (a section's padding:
181
+ 20 px above and below) and keeps it for the sides not edited.
182
+ - New layouts start with empty columns showing "Drop a block here" (canvas
183
+ only); an image without file shows a frame sized to its column.
184
+
185
+ ### Fixed
186
+ - Dropping an image opened an empty "Select an image" dialog of the old engine.
187
+ - A 2-column layout was named "Section" in the canvas and the panel; it is now
188
+ "2 columns", and every element carries its block's name, in both languages.
189
+ - Blocks inserted English texts ("Your text.", "Column 1", "Learn more"…) in a
190
+ French interface; their texts now follow the interface's language.
191
+ - An empty padding showed 0 while MJML renders 20 px (sections) or 10 px
192
+ (texts, top and bottom); editing one side set the others to 0.
193
+ - The button's "Label" field was not translated.
194
+ - The server-side project check now also reads the new project's `tag` field
195
+ (an `mj-raw` or `script` element in a tampered project is refused).
196
+
7
197
  ## [0.3.0] — 2026-10-13
8
198
 
9
199
  ### Added
package/README.md CHANGED
@@ -21,8 +21,9 @@ canvas you have to wire to MJML, storage, validation and sending yourself.
21
21
  Best Email Builder is the second option done once, properly, and given away:
22
22
 
23
23
  - **Free and MIT-licensed** — use it in commercial products, fork it, ship it.
24
- - **No account, no API key, no vendor.** It is a React component and a server
25
- handler you run in your own app, against your own database.
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.
26
27
  - **Readable from end to end.** The engine is hidden behind one adapter, every
27
28
  block and feature is a registry entry, and the rules a template must obey
28
29
  live in one validation folder — so it is easy to change what you need.
@@ -31,24 +32,58 @@ Best Email Builder is the second option done once, properly, and given away:
31
32
  oddly are all welcome — see [Contributing](#contributing).
32
33
 
33
34
  - **MJML under the hood** — every block compiles to battle-tested, table-based
34
- email HTML. You never write a `<table>` by hand.
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.
35
39
  - **The blocks emails are made of**: heading, text, image, button, divider,
36
40
  spacer, social links, menu, 1 to 4 columns, image + text, and a hero banner
37
41
  with a background image — sections take a background image too.
38
- - **Your UI, your storage, your rendering.** The builder is a React component
39
- with its own interface; GrapesJS is only the internal canvas engine, hidden
40
- behind an adapter. Templates are stored by *your* backend, as a versioned JSON
41
- project (the source of truth) plus server-compiled HTML.
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.
42
55
  - **Server-side rendering only.** The browser never sends HTML: the server
43
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.
65
+ - **Any text rewritten in one click** — select a text or a button: *Shorter*,
66
+ *Longer*, *Fix mistakes*, *Friendlier*, *More formal*, *Translate* into 13
67
+ languages, or say what to change in your own words. The text changes in
68
+ place, links and variables kept; Ctrl+Z brings the previous one back.
69
+ - **Three subject lines on demand** — drawn from the words of the email, each
70
+ from its own angle; a click puts one in the subject, your own stays in the
71
+ list to come back to.
72
+ - **The AI is optional** — a module of its own (`best-email-builder/ai`):
73
+ without it, no AI button, and none of its code in your bundle.
74
+ - **Your brand in every email** — give your website: the logo, colors and
75
+ font are read from it (or set by hand), and new emails, templates and
76
+ blocks start in them.
44
77
  - **A gallery of ready-made emails** — newsletter, welcome, promotion, event
45
78
  invitation… each one with the sender's footer and the unsubscribe link, so
46
79
  nobody starts from a blank page or forgets the legal mentions.
47
80
  - **Production features**: undo/redo, desktop/mobile canvas, autosave and local
48
81
  drafts, versions, conflict detection between concurrent editors, image
49
82
  uploads, server preview with quality checks (missing alt text, missing
50
- unsubscribe link, unknown variables, Gmail clipping…), test sends.
51
- - **Merge-tag variables your users create themselves** — searchable menu,
83
+ unsubscribe link, unknown variables, Gmail clipping…) and a dark-mode view
84
+ (as the Gmail and Outlook apps repaint emails), test sends.
85
+ - **Merge-tag variables your users create themselves** — searchable menu
86
+ (Ctrl K, like every search of the builder),
52
87
  insertion at the caret, in the subject line too, sample values for the
53
88
  preview and fallbacks for recipients without a value.
54
89
  - **Framework-agnostic server**: one Fetch-API handler for Next.js, Hono, Bun,
@@ -56,9 +91,10 @@ Best Email Builder is the second option done once, properly, and given away:
56
91
  - **English and French** built in, any other language through a dictionary.
57
92
 
58
93
  ```
59
- React UI ─► EmailEditor ─► GrapesJsAdapter ─► GrapesJS + grapesjs-mjml
60
- (ui/) (core/) (adapters/) internal engine
61
- │ project JSON + MJML
94
+ React UI ─► EmailEditor ─► NativeAdapter ─► the email: an MJML tree
95
+ (ui/) (core/) (engine/) │ canvas = the email compiled
96
+ │ by mjml-browser
97
+ │ project JSON + MJML
62
98
  Server: validation ─► MJML compilation ─► variables ─► links ─► final HTML
63
99
  ```
64
100
 
@@ -110,9 +146,9 @@ and each has its own entry point, so one never brings the other's code:
110
146
 
111
147
  | Block | Import | What it is |
112
148
  |---|---|---|
113
- | A — the bank | `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). *Edit*, *New template* and *Start from this base* only call `onEdit(template)` / `onCreate(start)` — it never mounts an editor, and does not load GrapesJS. |
114
- | B — the editor | `import { EmailBuilder } from 'best-email-builder/builder'` | The editor (above). Opens whatever the host hands it; knows nothing of the bank. |
115
- | A + B | `import { EmailStudio } from 'best-email-builder'` | The two wired together: the bank, then the editor, then back to the bank with the template just saved selected. The editor has no *Templates* button there — the bases are in the bank. |
149
+ | 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. |
150
+ | B — the editor | `import { EmailBuilder } from 'best-email-builder/builder'` | The editor (above). Opens whatever the host hands it; knows nothing of the library. |
151
+ | 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. |
116
152
 
117
153
  `best-email-builder` (the main entry) still exports everything; the two
118
154
  sub-entries are for an application that wants one block only. Both use the
@@ -126,12 +162,12 @@ import { EmailStudio, createHttpStorage } from 'best-email-builder';
126
162
  <EmailStudio storage={createHttpStorage()} locale="fr" sender={sender} />
127
163
  ```
128
164
 
129
- #### Block B with your own bank
165
+ #### Block B with your own library
130
166
 
131
- Your bank (a table, a sidebar, a modal — anything) only decides what the
167
+ Your library (a table, a sidebar, a modal — anything) only decides what the
132
168
  editor opens, and hears back when it saves or closes:
133
169
 
134
- | Your bank wants to… | Pass to `EmailBuilder` |
170
+ | Your library wants to… | Pass to `EmailBuilder` |
135
171
  |---|---|
136
172
  | open a saved template | `templateId={id}` (the editor loads it from `storage`) |
137
173
  | start from content it has (a base, an import, a copy) | `initialMjml={mjml}` and, optionally, `initialMeta={{ subject, preheader }}` |
@@ -150,7 +186,7 @@ import { EmailBuilder, createHttpStorage } from 'best-email-builder/builder';
150
186
  const storage = createHttpStorage();
151
187
 
152
188
  function Emails() {
153
- const [open, setOpen] = useState(null); // null = my bank
189
+ const [open, setOpen] = useState(null); // null = my library
154
190
 
155
191
  if (!open) {
156
192
  return (
@@ -267,6 +303,222 @@ const subject = applyVariablesToText(template.subject, values, variables);
267
303
  With an email provider that does its own merge, map the tags instead:
268
304
  `mapVariables(html, { 'contact.firstname': '{{ contact.FIRSTNAME }}' })`.
269
305
 
306
+ ### 4. AI (an optional module)
307
+
308
+ The AI is a module of its own, `best-email-builder/ai`. An application that
309
+ does not import it has no AI button anywhere, and none of the AI's code or
310
+ texts in its bundle. Given to the builder, it brings three things:
311
+
312
+ - **Generate** (toolbar) — asks one thing: what the email is for (a badge
313
+ among 68, grouped: sales, holidays, events, news, account, orders,
314
+ relationship, organization), with an optional line of details. The email is
315
+ written in the interface's language, with the builder's sender and
316
+ variables, and replaces the one being edited — the dialog says so before,
317
+ not after.
318
+ - **Rewrite with AI** (properties of a text or a button) and **Suggest
319
+ subjects** (under the subject), below.
320
+
321
+ ```jsx
322
+ import { createAi } from 'best-email-builder/ai';
323
+
324
+ <EmailBuilder storage={storage} ai={createAi(/* where the model runs, below */)} />
325
+ ```
326
+
327
+ Pass `generate: false`, `rewrite: false` or `suggestSubjects: false` to leave
328
+ a part out (its button with it), or functions of your own for any of them.
329
+
330
+ #### Where the model runs
331
+
332
+ **On your server** — the key stays there; the right choice for production:
333
+
334
+ ```js
335
+ // server
336
+ import { createEmailBuilderHandler, compileMjml } from 'best-email-builder/server';
337
+ import { createEmailGenerator, providerEmailModel } from 'best-email-builder/ai/server';
338
+
339
+ const handler = createEmailBuilderHandler({
340
+ /* … */
341
+ generator: createEmailGenerator({
342
+ model: providerEmailModel({ provider: 'openrouter', apiKey: process.env.OPENROUTER_API_KEY }),
343
+ compile: compileMjml, // MJML's own warnings go back to the model
344
+ }),
345
+ rateLimits: { generatePerHour: 30, rewritePerHour: 200, subjectsPerHour: 100 }, // per user (defaults)
346
+ });
347
+ ```
348
+
349
+ ```jsx
350
+ // browser: the handler's /generate, /rewrite and /subjects routes
351
+ <EmailBuilder storage={createHttpStorage()} ai={createAi()} />
352
+ ```
353
+
354
+ `createAi({ baseUrl, headers })` points at the same routes elsewhere, or with
355
+ the user's credentials. Without a `generator`, those routes answer 501.
356
+
357
+ **In the page, with each user's own key** — no server needed: the first AI
358
+ action opens a *Connect* dialog (the provider, a link to get a key, what is
359
+ free), the key is kept in that browser and sent only to the provider. A key
360
+ the provider refuses is forgotten and asked again. Off by default:
361
+
362
+ ```jsx
363
+ const ai = createAi({ provider: 'openrouter', askKey: true });
364
+ ```
365
+
366
+ `ai.key` reads and changes it: `get()`, `set(key)`, `forget()`,
367
+ `request()`, `subscribe(fn)`, `state()` (`{ asking, refused, connected }`).
368
+ This is what the live demo does.
369
+
370
+ **In the page, with one key for everyone** — prototypes and internal tools
371
+ only, the key is in the page's code: `createAi({ provider: 'groq', apiKey })`.
372
+
373
+ #### Providers
374
+
375
+ Every provider here speaks the OpenAI *chat completions* API; nothing to
376
+ install. `provider` is an id below, or `{ id, model, baseUrl, headers }` to
377
+ change the model or reach an endpoint of your own. `AI_PROVIDERS` lists them
378
+ (name, address, default model, where to get a key, what is free).
379
+
380
+ | `provider` | Default model | Free to start | Key |
381
+ |---|---|---|---|
382
+ | `'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) |
383
+ | `'gemini'` | `gemini-3.5-flash-lite` | free tier, rate-limited | [aistudio.google.com/apikey](https://aistudio.google.com/apikey) |
384
+ | `'groq'` | `openai/gpt-oss-120b` | free tier, rate-limited | [console.groq.com/keys](https://console.groq.com/keys) |
385
+ | `'cerebras'` | `gpt-oss-120b` | free tier, rate-limited | [cloud.cerebras.ai](https://cloud.cerebras.ai/) |
386
+ | `'mistral'` | `mistral-small-latest` | free *Experiment* plan | [console.mistral.ai/api-keys](https://console.mistral.ai/api-keys) |
387
+ | `'openai'` | `gpt-5-mini` | — | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) |
388
+ | `'deepseek'` | `deepseek-chat` | — | [platform.deepseek.com/api_keys](https://platform.deepseek.com/api_keys) |
389
+ | `'ollama'` | — (give `model`) | runs on your machine, no key | — |
390
+ | `'openai-compatible'` | — (give `baseUrl` and `model`) | LM Studio, vLLM, Together, a proxy of your own… | as it needs |
391
+
392
+ ```js
393
+ providerEmailModel({ provider: { id: 'groq', model: 'llama-4-scout' }, apiKey });
394
+ providerEmailModel({ provider: { baseUrl: 'http://localhost:1234/v1', model: 'qwen3' } });
395
+ ```
396
+
397
+ - Model names change often: the defaults were current at this release —
398
+ pass `model` to be sure of yours.
399
+ - The answer is asked as structured output (a JSON schema); a model that
400
+ refuses it is asked for a JSON object, then for plain JSON — once, then
401
+ remembered. A model whose structured output breaks the JSON (some do on the
402
+ quotes of HTML) can be told to skip it: `provider: { …, format: 'text' }`.
403
+ - The provider's failures become errors the interface explains, in its
404
+ language: `KEY_MISSING`, `KEY_REFUSED`, `MODEL_CREDITS`, `MODEL_BUSY`,
405
+ `MODEL_ERROR` (with the provider's own text in `detail`). None is a 401:
406
+ the key is the AI's, not the user's session.
407
+ - **Claude**: `claudeEmailModel({ client: new Anthropic() })` with the
408
+ Anthropic SDK (`npm install @anthropic-ai/sdk`) — `claude-opus-5-5` by
409
+ default, `effort: 'medium'`, structured output, streamed so a long email
410
+ does not hit a request timeout — or through OpenRouter with an
411
+ `anthropic/…` model and one key for every model.
412
+ - **Anything else** is a function `({ system, messages, schema, signal, task })
413
+ => Promise<string>` returning the JSON text — `createEmailGenerator({ model })`
414
+ on the server, `createAi({ model })` in the page.
415
+
416
+ A generation takes from 20 seconds to a minute or two: on a server, give the
417
+ route that long (`maxDuration` on Vercel, the proxy's timeout).
418
+
419
+ What comes out is never trusted as is: shorthand padding is expanded,
420
+ elements the builder does not edit (`mj-hero`, `mj-wrapper`, `mj-head`…) are
421
+ turned into sections and columns, stock photos are removed (the user picks
422
+ real images; the alt text stays), unknown variables become visible
423
+ `[placeholders]`, and the result must pass the same validation as a saved
424
+ template. When a problem remains, the model gets one more turn with the list.
425
+ The prompt (`SYSTEM_PROMPT`, `buildUserPrompt`) and the purposes
426
+ (`EMAIL_PURPOSES`) are exported from `best-email-builder/ai/server`.
427
+
428
+ **Rewrite with AI.** The same generator rewrites one text: with it, selecting
429
+ a text block or a button shows a *Rewrite with AI* section in its properties —
430
+ *Shorter*, *Longer*, *Fix mistakes*, *Friendlier*, *More formal*, *Translate*
431
+ (13 languages, under their own names) and a field to say what to change. The
432
+ answer replaces the text at once, in one step of the history: Ctrl+Z brings
433
+ the previous text back, so there is nothing to accept or compare. Selecting
434
+ another element cancels a rewrite in progress; if the text was edited
435
+ meanwhile, the user's words win and the answer is dropped.
436
+
437
+ - On a server it is `POST {base}/rewrite`; a generator without `rewrite`
438
+ answers 501.
439
+ - Every link (the same `href`, none added) and every variable (the same keys,
440
+ none added; a fallback after `|` may be translated) must survive, and the
441
+ HTML must pass the validator — otherwise the model gets one more turn, then
442
+ nothing is changed. A button label comes back as plain words.
443
+ - `claudeEmailModel` asks a rewrite for `rewriteEffort: 'low'` (a few seconds);
444
+ a model of your own gets `task: 'rewrite'` in its request to tell the two apart.
445
+ - `REWRITE_ACTIONS`, `REWRITE_LANGUAGES` and `REWRITE_SYSTEM_PROMPT` are
446
+ exported.
447
+
448
+ **Suggest subjects.** Under the subject of the email, *Suggest subjects* asks
449
+ for three other subject lines, drawn from the words of the email, each from
450
+ its own angle (clear, curious, personal or timely). They come as a list to
451
+ pick from, with the user's own subject first: a click puts one in the field,
452
+ another click another one, the first row brings the user's subject back.
453
+ *More ideas* asks for three new ones, *Done* closes the list.
454
+
455
+ - On a server it is `POST {base}/subjects` (`generator.suggestSubjects`);
456
+ 100 per user per hour.
457
+ - The subjects are plain lines, different from the current subject and from
458
+ each other, in the language of the email; a merge tag is allowed only from
459
+ the server's catalog. Otherwise the model gets one more turn; fewer than
460
+ three good ones are still shown.
461
+ - `claudeEmailModel` uses `rewriteEffort` for them as well (`task: 'subjects'`).
462
+ `emailWords(mjml)` (the words a reader sees) and `SUBJECTS_SYSTEM_PROMPT`
463
+ are exported.
464
+
465
+ The live demo has no server: there, the AI runs in the page with the
466
+ visitor's own OpenRouter key (`createAi({ provider: 'openrouter', askKey: true })`).
467
+
468
+ ### 5. Your brand (optional)
469
+
470
+ The *Brand* button opens one dialog. Its first field is the company's
471
+ website: *Get my brand* reads its logo, main color, headings color, font and
472
+ name, and fills the fields below. Everything can then be changed by hand
473
+ (upload a logo, pick a color), and a small preview follows every change.
474
+ *Save* keeps the brand for the whole workspace:
475
+
476
+ - the email being edited is repainted (the box *Also apply to this email* is
477
+ ticked by default) — one step of the history, Ctrl+Z brings the previous
478
+ look back;
479
+ - new emails, gallery templates, inserted blocks and AI-generated emails
480
+ start in the brand: the logo (or the name) at the top, the name in the
481
+ footer, buttons and banners in the main color, headings in their color.
482
+
483
+ Emails are repainted by **role**, not by exact color, so any email works —
484
+ a template, a generated one, one built by hand: filled buttons and colored
485
+ banners take the main color, and their text switches to white or dark
486
+ whichever reads; bold headings take the headings color; colored links take
487
+ the main color (or the headings color when the main one is too light to read
488
+ on white); gray footers and body text keep theirs.
489
+
490
+ ```js
491
+ import {
492
+ createBrandImporter,
493
+ createMemoryBrandRepository,
494
+ } from 'best-email-builder/server';
495
+
496
+ const handler = createEmailBuilderHandler({
497
+ // …
498
+ brandStore: createMemoryBrandRepository(), // one row: { get(), save(brand) }
499
+ brandImporter: createBrandImporter(), // optional: "Get my brand"
500
+ rateLimits: { brandImportsPerHour: 20 }, // per user (default)
501
+ });
502
+
503
+ // In the browser:
504
+ const storage = createHttpStorage({ brand: true }); // { brand: { import: false } } without an importer
505
+ ```
506
+
507
+ - Routes: `GET {base}/brand`, `PUT {base}/brand` (write permission),
508
+ `POST {base}/brand/import` with `{ url }` (write permission). The import
509
+ saves nothing: the user reviews the result first. A logo found on the site
510
+ is stored like an upload (PNG, JPEG or GIF; SVG and WebP logos are skipped,
511
+ since most email apps do not show them — the dialog says so).
512
+ - Without `brandImporter` the dialog has no website field; without
513
+ `brandStore` there is no *Brand* button.
514
+ - Some websites refuse automated readers (bot protection answers 403): the
515
+ dialog says so, and the brand is filled in by hand.
516
+ - `applyBrand(mjml, brand)` and `cleanBrand(brand)` are exported for your
517
+ own pipeline (e.g. rebranding stored templates).
518
+
519
+ The live demo keeps the brand in `localStorage` and has no website field
520
+ (reading a website needs a server).
521
+
270
522
  ## Component props
271
523
 
272
524
  | Prop | Default | Description |
@@ -286,6 +538,7 @@ With an email provider that does its own merge, map the tags instead:
286
538
  | `onSaved` | — | Called with the saved template. |
287
539
  | `onDirtyChange` | — | Called when the unsaved state changes. |
288
540
  | `dirtyCheckRef` | — | Ref receiving a function `() => boolean` (synchronous dirty check). |
541
+ | `ai` | — | The AI module, `createAi()` from `best-email-builder/ai` (see *AI*). Without it, no AI button and no AI code. |
289
542
 
290
543
  ## Storage
291
544
 
@@ -308,6 +561,9 @@ interface TemplateStorage {
308
561
  listVariables?(): Promise<Variable[]>; // enable user-created variables
309
562
  saveVariable?(variable, { create }): Promise<Variable>;
310
563
  deleteVariable?(key): Promise<void>;
564
+ loadBrand?(): Promise<Brand>; // enable the Brand button
565
+ saveBrand?(brand): Promise<Brand>;
566
+ importBrand?(url, { signal }): Promise<{ brand; notes }>; // enables "Get my brand"
311
567
  }
312
568
  ```
313
569
 
@@ -396,6 +652,20 @@ touch the rest of your page, whether or not you use Tailwind yourself.
396
652
  `nosniff` and a sandboxing CSP.
397
653
  - Previews are rendered in an iframe with an empty `sandbox`.
398
654
  - Test sends are limited (10 addresses, 20 sends per user per hour by default).
655
+ - Reading a brand from a website is a request your server makes to an address
656
+ a user typed, so it is fenced: http(s) on ports 80 and 443 only, no
657
+ credentials in the address, and every address the name resolves to must be
658
+ public — loopback, private networks, link-local and cloud metadata
659
+ (`169.254.169.254`), carrier-grade NAT, multicast and IPv4 hidden in IPv6
660
+ (mapped, NAT64, 6to4) are refused, at the lookup itself so a DNS answer
661
+ cannot slip through. Redirects (4 at most) are checked the same way. Pages,
662
+ stylesheets and logos have size limits after decompression, the whole
663
+ reading 8 seconds, and imports are limited per user (20 per hour).
664
+ - Generated emails pass the same MJML validation as saved ones; generation is
665
+ limited per user (30 per hour by default) and needs the `write` permission.
666
+ A rewritten text keeps its links and variables (none added) and passes the
667
+ same validation; rewrites are limited too (200 per user per hour), and so
668
+ are subject ideas (100), whose merge tags come from the server's catalog.
399
669
 
400
670
  ## Development
401
671
 
@@ -434,7 +704,7 @@ channel described in [SECURITY.md](SECURITY.md).
434
704
 
435
705
  ## License
436
706
 
437
- [MIT](LICENSE). GrapesJS and grapesjs-mjml are BSD-3-Clause, MJML is MIT.
707
+ [MIT](LICENSE). MJML and mjml-browser are MIT.
438
708
 
439
709
  ## Author
440
710
 
@@ -0,0 +1,68 @@
1
+ import {
2
+ AI_MESSAGES,
3
+ AI_PROVIDERS,
4
+ DEFAULT_CLAUDE_MODEL,
5
+ EMAIL_DRAFT_SCHEMA,
6
+ EMAIL_PURPOSES,
7
+ EmailGenerationError,
8
+ GENERATION_ERRORS,
9
+ MAX_DETAILS_LENGTH,
10
+ PURPOSE_CATEGORIES,
11
+ REWRITE_ACTIONS,
12
+ REWRITE_LANGUAGES,
13
+ REWRITE_SYSTEM_PROMPT,
14
+ SUBJECTS_SYSTEM_PROMPT,
15
+ SYSTEM_PROMPT,
16
+ buildUserPrompt,
17
+ claudeEmailModel,
18
+ cleanGenerateInput,
19
+ createEmailGenerator,
20
+ createRewriter,
21
+ createSubjectSuggester,
22
+ emailWords,
23
+ normalizeGeneratedMjml,
24
+ parseDraft,
25
+ providerEmailModel,
26
+ purposeById,
27
+ purposesByCategory,
28
+ resolveProvider,
29
+ reviewDraft,
30
+ reviewRewrite,
31
+ reviewSubjects
32
+ } from "./chunk-MPX5CWTZ.js";
33
+ import "./chunk-2RZHGAH5.js";
34
+ import "./chunk-5SASKCUC.js";
35
+ import "./chunk-DFNHICTF.js";
36
+ export {
37
+ AI_MESSAGES,
38
+ AI_PROVIDERS,
39
+ DEFAULT_CLAUDE_MODEL,
40
+ EMAIL_DRAFT_SCHEMA,
41
+ EMAIL_PURPOSES,
42
+ EmailGenerationError,
43
+ GENERATION_ERRORS,
44
+ MAX_DETAILS_LENGTH,
45
+ PURPOSE_CATEGORIES,
46
+ REWRITE_ACTIONS,
47
+ REWRITE_LANGUAGES,
48
+ REWRITE_SYSTEM_PROMPT,
49
+ SUBJECTS_SYSTEM_PROMPT,
50
+ SYSTEM_PROMPT,
51
+ buildUserPrompt,
52
+ claudeEmailModel,
53
+ cleanGenerateInput,
54
+ createEmailGenerator,
55
+ createRewriter,
56
+ createSubjectSuggester,
57
+ emailWords,
58
+ normalizeGeneratedMjml,
59
+ parseDraft,
60
+ providerEmailModel,
61
+ purposeById,
62
+ purposesByCategory,
63
+ resolveProvider,
64
+ reviewDraft,
65
+ reviewRewrite,
66
+ reviewSubjects
67
+ };
68
+ //# sourceMappingURL=ai-server.js.map