best-email-builder 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +329 -2
  4. package/dist/chunk-2DZ6BRIJ.js +652 -0
  5. package/dist/chunk-2DZ6BRIJ.js.map +1 -0
  6. package/dist/index.js +4275 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/server.js +1256 -0
  9. package/dist/server.js.map +1 -0
  10. package/dist/styles.css +2 -0
  11. package/dist/types/adapters/grapesjs/GrapesJsAdapter.d.ts +33 -0
  12. package/dist/types/adapters/grapesjs/engineConfig.d.ts +162 -0
  13. package/dist/types/adapters/grapesjs/unwrapModule.d.ts +18 -0
  14. package/dist/types/blocks/button/index.d.ts +3 -0
  15. package/dist/types/blocks/columns/index.d.ts +4 -0
  16. package/dist/types/blocks/divider/index.d.ts +3 -0
  17. package/dist/types/blocks/image/index.d.ts +3 -0
  18. package/dist/types/blocks/index.d.ts +2 -0
  19. package/dist/types/blocks/shared.d.ts +58 -0
  20. package/dist/types/blocks/spacer/index.d.ts +3 -0
  21. package/dist/types/blocks/structure/index.d.ts +8 -0
  22. package/dist/types/blocks/text/index.d.ts +11 -0
  23. package/dist/types/core/EmailEditor.d.ts +386 -0
  24. package/dist/types/core/constants.d.ts +18 -0
  25. package/dist/types/core/document.d.ts +32 -0
  26. package/dist/types/core/events.d.ts +30 -0
  27. package/dist/types/core/mjmlAttributes.d.ts +53 -0
  28. package/dist/types/core/registry.d.ts +22 -0
  29. package/dist/types/features/autosave/index.d.ts +19 -0
  30. package/dist/types/features/autosave/localDraft.d.ts +21 -0
  31. package/dist/types/features/autosave/scheduler.d.ts +35 -0
  32. package/dist/types/features/index.d.ts +4 -0
  33. package/dist/types/features/preview/PreviewButton.d.ts +17 -0
  34. package/dist/types/features/preview/index.d.ts +2 -0
  35. package/dist/types/features/templates/starter.d.ts +20 -0
  36. package/dist/types/features/test-email/TestEmailButton.d.ts +3 -0
  37. package/dist/types/features/test-email/index.d.ts +2 -0
  38. package/dist/types/features/variables/VariableDialog.d.ts +31 -0
  39. package/dist/types/features/variables/VariableInsertButton.d.ts +21 -0
  40. package/dist/types/features/variables/VariablePicker.d.ts +22 -0
  41. package/dist/types/features/variables/VariablesMenu.d.ts +18 -0
  42. package/dist/types/features/variables/catalog.d.ts +96 -0
  43. package/dist/types/features/variables/index.d.ts +2 -0
  44. package/dist/types/features/variables/service.d.ts +52 -0
  45. package/dist/types/features/versions/VersionsMenu.d.ts +3 -0
  46. package/dist/types/features/versions/index.d.ts +2 -0
  47. package/dist/types/i18n/fr.d.ts +2 -0
  48. package/dist/types/i18n/index.d.ts +22 -0
  49. package/dist/types/index.d.ts +25 -0
  50. package/dist/types/renderer/links.d.ts +19 -0
  51. package/dist/types/renderer/prepareMjml.d.ts +10 -0
  52. package/dist/types/server/assets.d.ts +84 -0
  53. package/dist/types/server/compileMjml.d.ts +14 -0
  54. package/dist/types/server/errors.d.ts +17 -0
  55. package/dist/types/server/handler.d.ts +39 -0
  56. package/dist/types/server/index.d.ts +19 -0
  57. package/dist/types/server/node.d.ts +7 -0
  58. package/dist/types/server/render.d.ts +80 -0
  59. package/dist/types/server/sendTest.d.ts +57 -0
  60. package/dist/types/server/templates.d.ts +240 -0
  61. package/dist/types/server/variables.d.ts +54 -0
  62. package/dist/types/storage/client.d.ts +200 -0
  63. package/dist/types/ui/BlockSidebar.d.ts +4 -0
  64. package/dist/types/ui/ElementActions.d.ts +13 -0
  65. package/dist/types/ui/EmailBuilder.d.ts +47 -0
  66. package/dist/types/ui/EmailBuilderBoundary.d.ts +14 -0
  67. package/dist/types/ui/EmailCanvas.d.ts +17 -0
  68. package/dist/types/ui/PropertiesPanel.d.ts +11 -0
  69. package/dist/types/ui/Toolbar.d.ts +21 -0
  70. package/dist/types/ui/context.d.ts +33 -0
  71. package/dist/types/ui/fields.d.ts +13 -0
  72. package/dist/types/ui/primitives/button.d.ts +12 -0
  73. package/dist/types/ui/primitives/cn.d.ts +2 -0
  74. package/dist/types/ui/primitives/color-picker.d.ts +10 -0
  75. package/dist/types/ui/primitives/fields.d.ts +6 -0
  76. package/dist/types/ui/primitives/misc.d.ts +55 -0
  77. package/dist/types/ui/primitives/overlays.d.ts +85 -0
  78. package/dist/types/ui/useEmailEditorState.d.ts +12 -0
  79. package/dist/types/validation/images.d.ts +30 -0
  80. package/dist/types/validation/mjmlValidator.d.ts +33 -0
  81. package/dist/types/validation/projectValidator.d.ts +30 -0
  82. package/dist/types/validation/quality.d.ts +20 -0
  83. package/dist/types/validation/urls.d.ts +14 -0
  84. package/package.json +108 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.1] — 2026-10-12
8
+
9
+ ### Fixed
10
+ - The builder did not start under webpack (Next.js): `grapesjs-mjml` arrived
11
+ wrapped twice by the bundler's CommonJS interop ("mjmlPlugin is not a
12
+ function"), and its French labels were lost the same way. Every interop
13
+ layer is now unwrapped, whatever the bundler.
14
+ - The published package no longer carries stale chunks from earlier builds:
15
+ `npm run build` starts from an empty `dist/`.
16
+
17
+ ## [0.1.0] — 2026-10-10
18
+
19
+ First public release.
20
+
21
+ ### Added
22
+ - `<EmailBuilder>` React component: blocks (text, image, button, divider,
23
+ spacer, 1/2/3 columns), drag and drop, element actions (move, duplicate,
24
+ delete), undo/redo, desktop/mobile canvas, properties panel.
25
+ - Save / load with versions, conflict detection, autosave and local drafts.
26
+ - Merge-tag variables, server preview with quality checks, test sends, image uploads.
27
+ - `best-email-builder/server`: Fetch-API handler, template and asset services, memory
28
+ repositories, MJML rendering, personalization helpers, Node bridge.
29
+ - English and French interfaces; `--eb-*` theming; dark theme.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Best Email Builder contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,330 @@
1
- # Temporary Holding Version
1
+ # Best Email Builder
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 API key, no vendor.** It is a React component and a server
25
+ handler you run in your own app, against your own database.
26
+ - **Readable from end to end.** The engine is hidden behind one adapter, every
27
+ block and feature is a registry entry, and the rules a template must obey
28
+ live in one validation folder — so it is easy to change what you need.
29
+ - **Open to contributions.** Bug reports, new blocks, new locales, storage
30
+ recipes for your database and fixes for a mail client that renders something
31
+ oddly are all welcome — see [Contributing](#contributing).
32
+
33
+ - **MJML under the hood** — every block compiles to battle-tested, table-based
34
+ email HTML. You never write a `<table>` by hand.
35
+ - **Your UI, your storage, your rendering.** The builder is a React component
36
+ with its own interface; GrapesJS is only the internal canvas engine, hidden
37
+ behind an adapter. Templates are stored by *your* backend, as a versioned JSON
38
+ project (the source of truth) plus server-compiled HTML.
39
+ - **Server-side rendering only.** The browser never sends HTML: the server
40
+ validates the project and the MJML, compiles, and stores its own HTML.
41
+ - **Production features**: undo/redo, desktop/mobile canvas, autosave and local
42
+ drafts, versions, conflict detection between concurrent editors, image
43
+ uploads, server preview with quality checks (missing alt text, missing
44
+ unsubscribe link, unknown variables, Gmail clipping…), test sends.
45
+ - **Merge-tag variables your users create themselves** — searchable menu,
46
+ insertion at the caret, in the subject line too, sample values for the
47
+ preview and fallbacks for recipients without a value.
48
+ - **Framework-agnostic server**: one Fetch-API handler for Next.js, Hono, Bun,
49
+ Deno, Cloudflare Workers — and Express / Node `http` through a bridge.
50
+ - **English and French** built in, any other language through a dictionary.
51
+
52
+ ```
53
+ React UI ─► EmailEditor ─► GrapesJsAdapter ─► GrapesJS + grapesjs-mjml
54
+ (ui/) (core/) (adapters/) internal engine
55
+ │ project JSON + MJML
56
+ Server: validation ─► MJML compilation ─► variables ─► links ─► final HTML
57
+ ```
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ npm install best-email-builder
63
+ ```
64
+
65
+ Peer dependencies: `react` and `react-dom` ≥ 18.2. The server entry needs
66
+ Node ≥ 20 (or any runtime with the Fetch API and `node:crypto`).
67
+
68
+ ## Quick start
69
+
70
+ ### 1. The component
71
+
72
+ ```jsx
73
+ import { EmailBuilder, createHttpStorage } from 'best-email-builder';
74
+ import 'best-email-builder/styles.css';
75
+
76
+ const storage = createHttpStorage({ baseUrl: '/api/email-builder' });
77
+
78
+ export default function TemplateEditor({ templateId, onClose }) {
79
+ return (
80
+ <div style={{ height: '100vh' }}>
81
+ <EmailBuilder
82
+ storage={storage}
83
+ templateId={templateId} // omit to create a new template
84
+ onClose={onClose}
85
+ onSaved={(template) => console.log('saved', template.id)}
86
+ />
87
+ </div>
88
+ );
89
+ }
90
+ ```
91
+
92
+ The builder fills its parent: give the parent a height. It is a client-only
93
+ component (the canvas needs the DOM). With Next.js, load it with
94
+ `dynamic(() => import(...), { ssr: false })` or render it from a
95
+ `'use client'` component.
96
+
97
+ ### 2. The server routes
98
+
99
+ ```js
100
+ // app/api/email-builder/[...path]/route.js (Next.js App Router)
101
+ import {
102
+ createEmailBuilderHandler,
103
+ createMemoryTemplateRepository,
104
+ createMemoryAssetRepository,
105
+ createMemoryVariableRepository,
106
+ } from 'best-email-builder/server';
107
+
108
+ const handler = createEmailBuilderHandler({
109
+ templates: createMemoryTemplateRepository(), // replace with your database
110
+ assets: createMemoryAssetRepository(), // replace with your database / object storage
111
+ variableStore: createMemoryVariableRepository(), // optional: user-created variables
112
+ authorize: async (request, { action }) => {
113
+ const user = await getUserFromSession(request); // your auth
114
+ return user ? { id: user.id, name: user.email } : null; // null → 401
115
+ },
116
+ publicUrl: process.env.PUBLIC_URL, // absolute image URLs in sent emails
117
+ transport: { // optional: enables "Send test"
118
+ send: ({ to, subject, html, text }) => mailer.sendMail({ from: 'no-reply@example.com', to, subject, html, text }),
119
+ },
120
+ });
121
+
122
+ export { handler as GET, handler as POST, handler as PUT, handler as DELETE };
123
+ ```
124
+
125
+ Express or plain Node:
126
+
127
+ ```js
128
+ import express from 'express';
129
+ import { createEmailBuilderHandler, toNodeListener } from 'best-email-builder/server';
130
+
131
+ const app = express();
132
+ app.use(toNodeListener(createEmailBuilderHandler({ /* … */ })));
133
+ ```
134
+
135
+ Uploaded images are served **publicly** at `GET {basePath}/assets/<key>`
136
+ (mail clients download them without a session). Make sure that path is not
137
+ behind your authentication middleware.
138
+
139
+ ### 3. Sending a campaign
140
+
141
+ The builder stores the compiled HTML; at send time, personalize it per
142
+ recipient:
143
+
144
+ ```js
145
+ import { personalize, applyVariablesToText } from 'best-email-builder/server';
146
+
147
+ const variables = await handler.catalog(); // system + user-created variables
148
+ const values = { 'contact.firstname': 'Ada', 'contact.email': 'ada@example.com' };
149
+ const html = personalize(template.html, {
150
+ values,
151
+ variables,
152
+ unsubscribeUrl: signedUnsubscribeUrl(recipient),
153
+ baseUrl: 'https://app.example.com', // makes uploaded images absolute
154
+ });
155
+ const subject = applyVariablesToText(template.subject, values, variables);
156
+ ```
157
+
158
+ With an email provider that does its own merge, map the tags instead:
159
+ `mapVariables(html, { 'contact.firstname': '{{ contact.FIRSTNAME }}' })`.
160
+
161
+ ## Component props
162
+
163
+ | Prop | Default | Description |
164
+ |---|---|---|
165
+ | `storage` | `createHttpStorage()` | Where templates and images go (see *Storage*). |
166
+ | `templateId` | `null` | Template to open; `null` starts from the starter template (or a local draft). |
167
+ | `locale` | `'en'` | `'en'`, `'fr'`, or any BCP 47 tag used with `messages`. |
168
+ | `messages` | — | Extra or overriding translations (`{ 'Save': 'Speichern', … }`). |
169
+ | `onNotify` | built-in notices | `(level, message) => void` to use your own toaster. |
170
+ | `variables` | `DEFAULT_VARIABLES` | Merge tags offered in the *Variables* menu. Pass the same list to the server. |
171
+ | `sender` | — | `{ name, address, logoUrl, logoWidth }` used by the starter template footer. |
172
+ | `starterMjml` | — | Replace the starter template entirely. |
173
+ | `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`. |
174
+ | `testRecipients` | `[]` | Addresses pre-filled in the *Send test* dialog. |
175
+ | `onClose` | — | Shows a *Back* button; called after confirming unsaved changes. |
176
+ | `onSaved` | — | Called with the saved template. |
177
+ | `onDirtyChange` | — | Called when the unsaved state changes. |
178
+ | `dirtyCheckRef` | — | Ref receiving a function `() => boolean` (synchronous dirty check). |
179
+
180
+ ## Storage
181
+
182
+ The component only knows this contract — implement it to talk to any backend,
183
+ or use `createHttpStorage()` with the server handler:
184
+
185
+ ```ts
186
+ interface TemplateStorage {
187
+ load(id): Promise<Template>;
188
+ create(payload): Promise<{ template; warnings }>;
189
+ duplicate(id): Promise<{ template; warnings }>;
190
+ save(id, payload): Promise<{ template; warnings; version }>;
191
+ listVersions(id): Promise<Version[]>;
192
+ getVersion(id, versionId): Promise<Version & { document; mjml }>;
193
+ uploadImage(file): Promise<{ path }>;
194
+ render?(input): Promise<{ html; warnings; quality }>; // enables Preview
195
+ sendTest?(input): Promise<{ sent; failed; invalid; results }>; // enables Send test
196
+ listVariables?(): Promise<Variable[]>; // enable user-created variables
197
+ saveVariable?(variable, { create }): Promise<Variable>;
198
+ deleteVariable?(key): Promise<void>;
199
+ }
200
+ ```
201
+
202
+ `createMemoryStorage()` implements it in memory (demos, tests, Storybook-like
203
+ playgrounds).
204
+
205
+ ## Server repositories
206
+
207
+ The handler applies the business rules (validation, compilation, versions,
208
+ concurrency); persistence is delegated to two small repositories. The memory
209
+ implementations (`createMemoryTemplateRepository`, `createMemoryAssetRepository`)
210
+ are the reference: copy their shape for SQL, MongoDB, S3…
211
+
212
+ - `templates.update(id, fields, { expectedRevision })` must be **atomic**
213
+ (`UPDATE … WHERE id = ? AND revision = ?`) and return `{ conflict: true, current }`
214
+ on mismatch — this is what prevents two editors from overwriting each other.
215
+ - `templates.addVersion(templateId, version, { keep })` numbers versions and
216
+ keeps the latest `keep` (50 by default).
217
+ - `assets.findBySha256 / insert / get` — images are deduplicated by content hash.
218
+
219
+ The full contracts are documented as JSDoc types in
220
+ [`src/server/templates.js`](src/server/templates.js) and
221
+ [`src/server/assets.js`](src/server/assets.js).
222
+
223
+ ## Variables
224
+
225
+ The editor only inserts tags; values are filled on the server.
226
+
227
+ **System variables** come from your application (read-only in the interface):
228
+
229
+ | Tag | Sample |
230
+ |---|---|
231
+ | `{{contact.firstname}}`, `{{contact.lastname}}`, `{{contact.email}}` | Jane, Doe, jane.doe@example.com |
232
+ | `{{company.name}}` | Example Inc. |
233
+ | `{{campaign.name}}` | Newsletter |
234
+ | `{{ unsubscribe }}` | the unsubscribe link (`unsubscribeUrl`) |
235
+
236
+ Define your own list with `defineVariables([...])` and pass it to both
237
+ `<EmailBuilder variables>` and `createEmailBuilderHandler({ variables })`.
238
+
239
+ **Custom variables** are created by users from the *Variables* menu
240
+ (*New variable*): a name ("Order number"), a tag proposed from it
241
+ (`{{order_number}}`, frozen once created since templates contain it), a group,
242
+ a **sample value** used by the preview and test sends, and a **value if empty**
243
+ for recipients without one. They are shared by every template and stored by the
244
+ `variableStore` repository (`createMemoryVariableRepository` is the reference).
245
+
246
+ Using them:
247
+
248
+ - the menu has a search field (↑/↓ and Enter work), and a search without
249
+ result offers to create the variable under that name;
250
+ - a click inserts the tag at the caret of the text being edited; the
251
+ unsubscribe link becomes the URL of a selected button;
252
+ - the `{ }` buttons next to *Subject* and *Preview text* insert a tag there;
253
+ - `{{contact.firstname|there}}` sets an inline fallback for one place;
254
+ - the preview flags unknown tags (`{{contact.fistname}}`) before they reach anyone.
255
+
256
+ Values are HTML-escaped. A missing value falls back to the inline fallback,
257
+ then to the variable's *value if empty*, then to an empty string — a raw tag
258
+ never reaches a recipient.
259
+
260
+ ## Theming
261
+
262
+ All colours, radii and fonts are CSS custom properties prefixed `--eb-`
263
+ (see [`src/styles.css`](src/styles.css)). Override them on `:root`:
264
+
265
+ ```css
266
+ :root {
267
+ --eb-primary: #0f766e;
268
+ --eb-primary-foreground: #fff;
269
+ --eb-font-sans: 'Inter', sans-serif;
270
+ }
271
+ ```
272
+
273
+ A dark theme applies under a `.dark` ancestor. The stylesheet uses Tailwind
274
+ CSS v4 with an `eb:` prefix and a reset scoped to the builder: it does not
275
+ touch the rest of your page, whether or not you use Tailwind yourself.
276
+
277
+ ## Security model
278
+
279
+ - The server never stores HTML from the browser: it compiles its own.
280
+ - MJML and projects are validated against an allow-list (no scripts, event
281
+ handlers, `javascript:` URLs, `mj-include`, conditional comments…).
282
+ - Uploaded images are type-checked from their bytes (PNG, JPEG, GIF; SVG is
283
+ refused), limited to 2 MB, keyed by content hash and served with
284
+ `nosniff` and a sandboxing CSP.
285
+ - Previews are rendered in an iframe with an empty `sandbox`.
286
+ - Test sends are limited (10 addresses, 20 sends per user per hour by default).
287
+
288
+ ## Development
289
+
290
+ ```bash
291
+ npm install
292
+ npm run dev # Ladle playground on http://localhost:61010
293
+ npm test # unit tests (node:test)
294
+ npm run lint
295
+ npm run typecheck
296
+ npm run test:stories # Playwright against the playground
297
+ npm run example # full example app (React + server)
298
+ npm run demo # static demo (the GitHub Pages one)
299
+ npm run test:e2e # Playwright against the example app
300
+ npm run build # dist/ (ESM + styles.css + types)
301
+ ```
302
+
303
+ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the internals and
304
+ [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
305
+
306
+ ## Contributing
307
+
308
+ This project is built for and with the developer community. Good first
309
+ contributions:
310
+
311
+ - **a new block** — one folder in `src/blocks/` and one line in its registry;
312
+ - **a new interface language** — a dictionary like `src/i18n/fr.js`
313
+ (`npm run check:i18n` lists the missing keys);
314
+ - **a storage recipe** — the memory repositories are the reference shape for
315
+ PostgreSQL, MongoDB, S3 and friends;
316
+ - **a rendering report** — a template that looks wrong in a given mail client,
317
+ with a screenshot and the client version.
318
+
319
+ Open an [issue](https://github.com/SergeMiro/best-email-builder/issues) to
320
+ discuss a larger change first. Security problems go through the private
321
+ channel described in [SECURITY.md](SECURITY.md).
322
+
323
+ ## License
324
+
325
+ [MIT](LICENSE). GrapesJS and grapesjs-mjml are BSD-3-Clause, MJML is MIT.
326
+
327
+ ## Author
328
+
329
+ Built and maintained by [Sergiy Miro](https://smiro.dev/projects) — more
330
+ open-source work and case studies on [smiro.dev](https://smiro.dev/projects).