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.
- package/CHANGELOG.md +190 -0
- package/README.md +290 -20
- package/dist/ai-server.js +68 -0
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.js +852 -0
- package/dist/ai.js.map +1 -0
- package/dist/builder.js +16 -8
- package/dist/chunk-2RZHGAH5.js +64 -0
- package/dist/chunk-2RZHGAH5.js.map +1 -0
- package/dist/chunk-3LKF4QBL.js +595 -0
- package/dist/chunk-3LKF4QBL.js.map +1 -0
- package/dist/chunk-5SASKCUC.js +251 -0
- package/dist/chunk-5SASKCUC.js.map +1 -0
- package/dist/{chunk-JOC752I3.js → chunk-6D3SLT5W.js} +27 -29
- package/dist/chunk-6D3SLT5W.js.map +1 -0
- package/dist/chunk-7U6BAKM7.js +119 -0
- package/dist/chunk-7U6BAKM7.js.map +1 -0
- package/dist/chunk-DFNHICTF.js +1359 -0
- package/dist/chunk-DFNHICTF.js.map +1 -0
- package/dist/chunk-ECHGJZ2W.js +327 -0
- package/dist/chunk-ECHGJZ2W.js.map +1 -0
- package/dist/chunk-KXJJSVN5.js +98 -0
- package/dist/chunk-KXJJSVN5.js.map +1 -0
- package/dist/{chunk-ZSUHOCCE.js → chunk-LXR4QTGP.js} +123 -579
- package/dist/chunk-LXR4QTGP.js.map +1 -0
- package/dist/{chunk-BVOVAEEJ.js → chunk-LYMJ2TBX.js} +2 -2
- package/dist/chunk-MPX5CWTZ.js +1687 -0
- package/dist/chunk-MPX5CWTZ.js.map +1 -0
- package/dist/chunk-PB7YZ3WP.js +5635 -0
- package/dist/chunk-PB7YZ3WP.js.map +1 -0
- package/dist/chunk-UFKCLU2D.js +255 -0
- package/dist/chunk-UFKCLU2D.js.map +1 -0
- package/dist/index.js +51 -20
- package/dist/index.js.map +1 -1
- package/dist/library.js +12 -7
- package/dist/server.js +513 -214
- package/dist/server.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/types/ai/ConnectAi.d.ts +12 -0
- package/dist/types/ai/GenerateButton.d.ts +4 -0
- package/dist/types/ai/RewriteText.d.ts +5 -0
- package/dist/types/ai/SubjectIdeas.d.ts +25 -0
- package/dist/types/ai/chip.d.ts +1 -0
- package/dist/types/ai/claude.d.ts +21 -0
- package/dist/types/ai/createAi.d.ts +66 -0
- package/dist/types/ai/draft.d.ts +163 -0
- package/dist/types/ai/generator.d.ts +123 -0
- package/dist/types/ai/i18n/fr.d.ts +2 -0
- package/dist/types/ai/i18n/index.d.ts +2 -0
- package/dist/types/ai/index.d.ts +9 -0
- package/dist/types/ai/keys.d.ts +46 -0
- package/dist/types/ai/prompt.d.ts +76 -0
- package/dist/types/ai/providers.d.ts +67 -0
- package/dist/types/ai/purposes.d.ts +48 -0
- package/dist/types/ai/rewrite.d.ts +83 -0
- package/dist/types/ai/server.d.ts +9 -0
- package/dist/types/ai/subjects.d.ts +97 -0
- package/dist/types/blocks/shared.d.ts +20 -0
- package/dist/types/core/EmailEditor.d.ts +72 -19
- package/dist/types/core/constants.d.ts +1 -1
- package/dist/types/core/mjmlDefaults.d.ts +13 -0
- package/dist/types/core/scrollbars.d.ts +1 -1
- package/dist/types/defaultEditor.d.ts +4 -1
- package/dist/types/engine/NativeAdapter.d.ts +28 -0
- package/dist/types/engine/canvas/CanvasView.d.ts +88 -0
- package/dist/types/engine/canvas/canvasStyles.d.ts +3 -0
- package/dist/types/engine/canvas/icons.d.ts +14 -0
- package/dist/types/engine/canvas/morph.d.ts +21 -0
- package/dist/types/engine/canvasMjml.d.ts +30 -0
- package/dist/types/engine/mjml.d.ts +31 -0
- package/dist/types/engine/model.d.ts +130 -0
- package/dist/types/engine/placement.d.ts +69 -0
- package/dist/types/engine/richText.d.ts +15 -0
- package/dist/types/engine/unwrapModule.d.ts +29 -0
- package/dist/types/features/brand/BrandButton.d.ts +4 -0
- package/dist/types/features/brand/brand.d.ts +108 -0
- package/dist/types/features/brand/extract.d.ts +61 -0
- package/dist/types/features/brand/index.d.ts +2 -0
- package/dist/types/features/brand/messages.d.ts +13 -0
- package/dist/types/features/brand/useBrand.d.ts +22 -0
- package/dist/types/features/preview/PreviewButton.d.ts +1 -1
- package/dist/types/features/preview/darkMode.d.ts +65 -0
- package/dist/types/features/variables/VariablePicker.d.ts +3 -2
- package/dist/types/i18n/index.d.ts +8 -0
- package/dist/types/index.d.ts +5 -2
- package/dist/types/server/brand.d.ts +32 -0
- package/dist/types/server/brandImport.d.ts +93 -0
- package/dist/types/server/compileMjml.d.ts +2 -0
- package/dist/types/server/handler.d.ts +26 -0
- package/dist/types/server/index.d.ts +5 -1
- package/dist/types/storage/client.d.ts +57 -3
- package/dist/types/ui/EmailBuilder.d.ts +5 -1
- package/dist/types/ui/EmailStudio.d.ts +6 -1
- package/dist/types/ui/PropertiesPanel.d.ts +2 -1
- package/dist/types/ui/fields.d.ts +2 -1
- package/dist/types/ui/primitives/button.d.ts +1 -1
- package/dist/types/ui/primitives/color-picker.d.ts +2 -1
- package/dist/types/ui/primitives/searchShortcut.d.ts +16 -0
- package/dist/types/ui/useLayoutWidth.d.ts +1 -1
- package/package.json +27 -7
- package/dist/chunk-3M36VVXS.js +0 -665
- package/dist/chunk-3M36VVXS.js.map +0 -1
- package/dist/chunk-57CTQNTK.js +0 -56
- package/dist/chunk-57CTQNTK.js.map +0 -1
- package/dist/chunk-ILRAQTHS.js +0 -3677
- package/dist/chunk-ILRAQTHS.js.map +0 -1
- package/dist/chunk-JOC752I3.js.map +0 -1
- package/dist/chunk-ZSUHOCCE.js.map +0 -1
- package/dist/types/adapters/grapesjs/GrapesJsAdapter.d.ts +0 -33
- package/dist/types/adapters/grapesjs/engineConfig.d.ts +0 -148
- package/dist/types/adapters/grapesjs/unwrapModule.d.ts +0 -18
- /package/dist/{chunk-BVOVAEEJ.js.map → chunk-LYMJ2TBX.js.map} +0 -0
package/dist/server.js
CHANGED
|
@@ -5,38 +5,54 @@ import {
|
|
|
5
5
|
sampleValues,
|
|
6
6
|
unknownVariables,
|
|
7
7
|
usedVariables
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-LYMJ2TBX.js";
|
|
9
9
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
BRAND_IMPORT_ERRORS,
|
|
11
|
+
brandFromCss,
|
|
12
|
+
imageSize,
|
|
13
|
+
logoWidthFor,
|
|
14
|
+
readPage
|
|
15
|
+
} from "./chunk-ECHGJZ2W.js";
|
|
16
|
+
import {
|
|
17
|
+
simulateDarkMode
|
|
18
|
+
} from "./chunk-UFKCLU2D.js";
|
|
15
19
|
import {
|
|
16
20
|
ASSET_KEY_PATTERN,
|
|
17
|
-
DEFAULT_ASSETS_PATH,
|
|
18
|
-
DEFAULT_VARIABLES,
|
|
19
21
|
EMAIL_BUILDER_VERSION,
|
|
20
22
|
EMAIL_DOCUMENT_SCHEMA_VERSION,
|
|
21
23
|
EmailDocumentError,
|
|
22
|
-
LOCALES,
|
|
23
24
|
MAX_IMAGE_BYTES,
|
|
25
|
+
createDocument,
|
|
26
|
+
detectImage,
|
|
27
|
+
readDocument,
|
|
28
|
+
serializeDocument
|
|
29
|
+
} from "./chunk-7U6BAKM7.js";
|
|
30
|
+
import {
|
|
31
|
+
IMAGE_ATTRIBUTES,
|
|
32
|
+
LINK_ATTRIBUTES,
|
|
33
|
+
assertValidMjml,
|
|
34
|
+
isSafeUrl,
|
|
35
|
+
validateMjml
|
|
36
|
+
} from "./chunk-5SASKCUC.js";
|
|
37
|
+
import {
|
|
38
|
+
DEFAULT_ASSETS_PATH,
|
|
39
|
+
DEFAULT_VARIABLES,
|
|
40
|
+
LOCALES,
|
|
24
41
|
absoluteAssetUrls,
|
|
42
|
+
applyBrand,
|
|
25
43
|
applyServiceLinks,
|
|
26
|
-
|
|
44
|
+
cleanBrand,
|
|
27
45
|
createTranslator,
|
|
28
46
|
defineVariables,
|
|
29
|
-
detectImage,
|
|
30
47
|
identity,
|
|
31
48
|
isAssetPath,
|
|
32
49
|
keyFromLabel,
|
|
33
50
|
mergeVariables,
|
|
34
|
-
|
|
35
|
-
serializeDocument,
|
|
51
|
+
messagesOf,
|
|
36
52
|
tokenOf,
|
|
37
53
|
translatorFor,
|
|
38
54
|
validateCustomVariable
|
|
39
|
-
} from "./chunk-
|
|
55
|
+
} from "./chunk-DFNHICTF.js";
|
|
40
56
|
|
|
41
57
|
// src/server/assets.js
|
|
42
58
|
import { createHash } from "crypto";
|
|
@@ -130,198 +146,6 @@ function createMemoryAssetRepository() {
|
|
|
130
146
|
};
|
|
131
147
|
}
|
|
132
148
|
|
|
133
|
-
// src/validation/mjmlValidator.js
|
|
134
|
-
var MJML_MAX_LENGTH = 512 * 1024;
|
|
135
|
-
var MJML_TAGS = /* @__PURE__ */ new Set([
|
|
136
|
-
"mjml",
|
|
137
|
-
"mj-head",
|
|
138
|
-
"mj-body",
|
|
139
|
-
"mj-attributes",
|
|
140
|
-
"mj-all",
|
|
141
|
-
"mj-class",
|
|
142
|
-
"mj-preview",
|
|
143
|
-
"mj-title",
|
|
144
|
-
"mj-breakpoint",
|
|
145
|
-
"mj-wrapper",
|
|
146
|
-
"mj-section",
|
|
147
|
-
"mj-group",
|
|
148
|
-
"mj-column",
|
|
149
|
-
"mj-text",
|
|
150
|
-
"mj-image",
|
|
151
|
-
"mj-button",
|
|
152
|
-
"mj-divider",
|
|
153
|
-
"mj-spacer",
|
|
154
|
-
"mj-table",
|
|
155
|
-
"mj-social",
|
|
156
|
-
"mj-social-element",
|
|
157
|
-
"mj-navbar",
|
|
158
|
-
"mj-navbar-link",
|
|
159
|
-
"mj-hero"
|
|
160
|
-
]);
|
|
161
|
-
var INLINE_TAGS = /* @__PURE__ */ new Set([
|
|
162
|
-
"a",
|
|
163
|
-
"b",
|
|
164
|
-
"strong",
|
|
165
|
-
"i",
|
|
166
|
-
"em",
|
|
167
|
-
"u",
|
|
168
|
-
"s",
|
|
169
|
-
"strike",
|
|
170
|
-
"del",
|
|
171
|
-
"br",
|
|
172
|
-
"span",
|
|
173
|
-
"p",
|
|
174
|
-
"div",
|
|
175
|
-
"font",
|
|
176
|
-
"sup",
|
|
177
|
-
"sub",
|
|
178
|
-
"small",
|
|
179
|
-
"big",
|
|
180
|
-
"ul",
|
|
181
|
-
"ol",
|
|
182
|
-
"li",
|
|
183
|
-
"h1",
|
|
184
|
-
"h2",
|
|
185
|
-
"h3",
|
|
186
|
-
"h4",
|
|
187
|
-
"h5",
|
|
188
|
-
"h6",
|
|
189
|
-
"blockquote",
|
|
190
|
-
"code",
|
|
191
|
-
"pre",
|
|
192
|
-
"hr",
|
|
193
|
-
"img",
|
|
194
|
-
"table",
|
|
195
|
-
"tbody",
|
|
196
|
-
"thead",
|
|
197
|
-
"tr",
|
|
198
|
-
"td",
|
|
199
|
-
"th"
|
|
200
|
-
]);
|
|
201
|
-
var FORBIDDEN_TAGS = /* @__PURE__ */ new Map([
|
|
202
|
-
["mj-include", "mj-include would read a file from the server"],
|
|
203
|
-
["mj-raw", "mj-raw (free HTML) is not allowed"],
|
|
204
|
-
["mj-style", "mj-style (free CSS) is outside the builder"],
|
|
205
|
-
["mj-font", "mj-font (external font) is outside the builder"],
|
|
206
|
-
["script", "script not allowed"],
|
|
207
|
-
["iframe", "iframe not allowed"],
|
|
208
|
-
["frame", "frame not allowed"],
|
|
209
|
-
["object", "object not allowed"],
|
|
210
|
-
["embed", "embed not allowed"],
|
|
211
|
-
["form", "form not allowed"],
|
|
212
|
-
["input", "form field not allowed"],
|
|
213
|
-
["button", "HTML button not allowed (use the Button block)"],
|
|
214
|
-
["textarea", "form field not allowed"],
|
|
215
|
-
["select", "form field not allowed"],
|
|
216
|
-
["style", "style sheet not allowed"],
|
|
217
|
-
["link", "external resource not allowed"],
|
|
218
|
-
["meta", "meta tag not allowed"],
|
|
219
|
-
["base", "base tag not allowed"],
|
|
220
|
-
["svg", "SVG not allowed"],
|
|
221
|
-
["math", "MathML not allowed"]
|
|
222
|
-
]);
|
|
223
|
-
var TAG_RE = /<\s*(\/?)\s*([a-zA-Z][\w:-]*)([^>]*)>/g;
|
|
224
|
-
var ATTR_RE = /([^\s=/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
225
|
-
function* attributesOf(source) {
|
|
226
|
-
ATTR_RE.lastIndex = 0;
|
|
227
|
-
let match;
|
|
228
|
-
while (match = ATTR_RE.exec(source)) {
|
|
229
|
-
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
|
230
|
-
yield { name: match[1].toLowerCase(), value };
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
function styleIsSafe(style) {
|
|
234
|
-
const css = decodeEntities(style).toLowerCase().replace(/\s+/g, "");
|
|
235
|
-
if (css.includes("expression(") || css.includes("javascript:") || css.includes("behavior:")) return false;
|
|
236
|
-
const urls = css.match(/url\(([^)]*)\)/g) || [];
|
|
237
|
-
return urls.every((u) => isSafeUrl(u.slice(4, -1).replace(/^['"]|['"]$/g, ""), "image"));
|
|
238
|
-
}
|
|
239
|
-
function validateMjml(mjml, { t = identity } = {}) {
|
|
240
|
-
const issues = [];
|
|
241
|
-
const source = typeof mjml === "string" ? mjml : "";
|
|
242
|
-
if (!source.trim()) {
|
|
243
|
-
return { ok: false, issues: [{ code: "EMPTY", message: t("Empty MJML") }] };
|
|
244
|
-
}
|
|
245
|
-
if (source.length > MJML_MAX_LENGTH) {
|
|
246
|
-
issues.push({
|
|
247
|
-
code: "TOO_LARGE",
|
|
248
|
-
message: t("MJML too large ({size} KB, maximum {max} KB)", {
|
|
249
|
-
size: Math.round(source.length / 1024),
|
|
250
|
-
max: MJML_MAX_LENGTH / 1024
|
|
251
|
-
})
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
const withoutComments = source.replace(/<!--([\s\S]*?)-->/g, (whole, body) => {
|
|
255
|
-
if (/\[if\s/i.test(body)) {
|
|
256
|
-
issues.push({ code: "CONDITIONAL_COMMENT", message: t("conditional comment not allowed") });
|
|
257
|
-
}
|
|
258
|
-
return "";
|
|
259
|
-
});
|
|
260
|
-
if (/<!\[CDATA\[/i.test(withoutComments)) {
|
|
261
|
-
issues.push({ code: "CDATA", message: t("CDATA section not allowed") });
|
|
262
|
-
}
|
|
263
|
-
const trimmed = withoutComments.trim();
|
|
264
|
-
if (!/^<mjml[\s>]/i.test(trimmed)) {
|
|
265
|
-
issues.push({ code: "ROOT", message: t("the document must start with <mjml>") });
|
|
266
|
-
}
|
|
267
|
-
if (!/<mj-body[\s>]/i.test(trimmed)) {
|
|
268
|
-
issues.push({ code: "BODY", message: t("missing <mj-body>") });
|
|
269
|
-
}
|
|
270
|
-
TAG_RE.lastIndex = 0;
|
|
271
|
-
let match;
|
|
272
|
-
while (match = TAG_RE.exec(withoutComments)) {
|
|
273
|
-
const closing = match[1] === "/";
|
|
274
|
-
const tag = match[2].toLowerCase();
|
|
275
|
-
if (FORBIDDEN_TAGS.has(tag)) {
|
|
276
|
-
const template = FORBIDDEN_TAGS.get(tag);
|
|
277
|
-
issues.push({ code: "FORBIDDEN_TAG", message: template ? t(template) : tag, tag });
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
if (!MJML_TAGS.has(tag) && !INLINE_TAGS.has(tag)) {
|
|
281
|
-
issues.push({ code: "UNKNOWN_TAG", message: t("tag not allowed: <{tag}>", { tag }), tag });
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
if (closing) continue;
|
|
285
|
-
for (const { name, value } of attributesOf(match[3])) {
|
|
286
|
-
if (name.startsWith("on")) {
|
|
287
|
-
issues.push({
|
|
288
|
-
code: "EVENT_ATTRIBUTE",
|
|
289
|
-
message: t("attribute {name} not allowed", { name }),
|
|
290
|
-
tag,
|
|
291
|
-
attribute: name
|
|
292
|
-
});
|
|
293
|
-
} else if (LINK_ATTRIBUTES.has(name) && !isSafeUrl(value, "link")) {
|
|
294
|
-
issues.push({ code: "UNSAFE_URL", message: t("URL rejected ({name})", { name }), tag, attribute: name });
|
|
295
|
-
} else if (IMAGE_ATTRIBUTES.has(name) && !isSafeUrl(value, "image")) {
|
|
296
|
-
issues.push({
|
|
297
|
-
code: "UNSAFE_IMAGE",
|
|
298
|
-
message: t("image rejected ({name}): https only", { name }),
|
|
299
|
-
tag,
|
|
300
|
-
attribute: name
|
|
301
|
-
});
|
|
302
|
-
} else if (name === "style" && !styleIsSafe(value)) {
|
|
303
|
-
issues.push({ code: "UNSAFE_STYLE", message: t("inline style rejected"), tag, attribute: name });
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
return { ok: issues.length === 0, issues };
|
|
308
|
-
}
|
|
309
|
-
function assertValidMjml(mjml, { t = identity } = {}) {
|
|
310
|
-
const { ok, issues } = validateMjml(mjml, { t });
|
|
311
|
-
if (ok) return;
|
|
312
|
-
const shown = issues.slice(0, 3).map((i) => i.message).join("; ");
|
|
313
|
-
const more = issues.length - 3;
|
|
314
|
-
const error = (
|
|
315
|
-
/** @type {Error & {status?: number, issues?: ValidationIssue[]}} */
|
|
316
|
-
new Error(
|
|
317
|
-
more > 0 ? t("Template rejected: {issues} (+{more})", { issues: shown, more }) : t("Template rejected: {issues}", { issues: shown })
|
|
318
|
-
)
|
|
319
|
-
);
|
|
320
|
-
error.status = 422;
|
|
321
|
-
error.issues = issues;
|
|
322
|
-
throw error;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
149
|
// src/validation/quality.js
|
|
326
150
|
var GMAIL_CLIP_BYTES = 102 * 1024;
|
|
327
151
|
function count(source, pattern) {
|
|
@@ -361,13 +185,13 @@ function qualityReport({ mjml, html = "", subject = null }, { t = identity } = {
|
|
|
361
185
|
message: t("Button without link ({count})", { count: noHref })
|
|
362
186
|
});
|
|
363
187
|
}
|
|
364
|
-
const
|
|
365
|
-
if (
|
|
188
|
+
const http2 = count(source, /\shref="http:\/\//gi);
|
|
189
|
+
if (http2) {
|
|
366
190
|
issues.push({
|
|
367
191
|
code: "HTTP_LINK",
|
|
368
192
|
level: "info",
|
|
369
|
-
count:
|
|
370
|
-
message: t("Insecure http link ({count})", { count:
|
|
193
|
+
count: http2,
|
|
194
|
+
message: t("Insecure http link ({count})", { count: http2 })
|
|
371
195
|
});
|
|
372
196
|
}
|
|
373
197
|
if (subject !== null && !String(subject).trim()) {
|
|
@@ -410,6 +234,13 @@ function prepareMjml(mjml, { title = null, preheader = null, lang = "en" } = {})
|
|
|
410
234
|
|
|
411
235
|
// src/server/compileMjml.js
|
|
412
236
|
import mjml2html from "mjml";
|
|
237
|
+
var CLIENT_METAS = '<meta name="x-apple-disable-message-reformatting"><meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">';
|
|
238
|
+
function withClientMetas(html) {
|
|
239
|
+
if (!html || html.includes("x-apple-disable-message-reformatting")) return html;
|
|
240
|
+
const viewport = /<meta name="viewport"[^>]*>/i.exec(html);
|
|
241
|
+
if (viewport) return html.slice(0, viewport.index + viewport[0].length) + CLIENT_METAS + html.slice(viewport.index + viewport[0].length);
|
|
242
|
+
return html.replace(/<head>/i, (head) => head + CLIENT_METAS);
|
|
243
|
+
}
|
|
413
244
|
async function compileMjml(mjml) {
|
|
414
245
|
const compile = (
|
|
415
246
|
/** @type {any} */
|
|
@@ -424,7 +255,7 @@ async function compileMjml(mjml) {
|
|
|
424
255
|
fonts: {}
|
|
425
256
|
});
|
|
426
257
|
return {
|
|
427
|
-
html: String(result?.html || ""),
|
|
258
|
+
html: withClientMetas(String(result?.html || "")),
|
|
428
259
|
warnings: (result?.errors || []).map((e) => ({
|
|
429
260
|
line: e.line,
|
|
430
261
|
message: String(e.formattedMessage || e.message || e),
|
|
@@ -564,7 +395,7 @@ function validateProject(raw, { t = identity } = {}) {
|
|
|
564
395
|
for (
|
|
565
396
|
const [key, pattern] of
|
|
566
397
|
/** @type {[string, RegExp][]} */
|
|
567
|
-
[["type", DANGEROUS_TYPES], ["tagName", DANGEROUS_TAGS]]
|
|
398
|
+
[["type", DANGEROUS_TYPES], ["tagName", DANGEROUS_TAGS], ["tag", DANGEROUS_TAGS]]
|
|
568
399
|
) {
|
|
569
400
|
const value = node[key];
|
|
570
401
|
if (typeof value === "string" && pattern.test(value)) {
|
|
@@ -929,9 +760,377 @@ function createMemoryVariableRepository() {
|
|
|
929
760
|
};
|
|
930
761
|
}
|
|
931
762
|
|
|
763
|
+
// src/server/brand.js
|
|
764
|
+
function createBrandService({ repository = null, t = identity }) {
|
|
765
|
+
return {
|
|
766
|
+
/** The brand, `{}` when none was set (or no repository). */
|
|
767
|
+
async get() {
|
|
768
|
+
return repository ? cleanBrand(await repository.get()) : {};
|
|
769
|
+
},
|
|
770
|
+
/** @param {unknown} input @param {{id?: string|number, name?: string}|null} [user] */
|
|
771
|
+
async save(input, user = null) {
|
|
772
|
+
if (!repository) throw new HttpError(501, t("The brand is not configured"), "NO_BRAND_STORE");
|
|
773
|
+
if (!input || typeof input !== "object") throw new HttpError(400, t("Invalid JSON"), "INVALID_JSON");
|
|
774
|
+
const brand = cleanBrand(input);
|
|
775
|
+
await repository.save({ ...brand, updatedBy: user ? String(user.name ?? user.id ?? "") || null : null });
|
|
776
|
+
return brand;
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
function createMemoryBrandRepository() {
|
|
781
|
+
let row = null;
|
|
782
|
+
return {
|
|
783
|
+
async get() {
|
|
784
|
+
return row ? { ...row } : null;
|
|
785
|
+
},
|
|
786
|
+
async save(brand) {
|
|
787
|
+
row = { ...brand, updatedAt: /* @__PURE__ */ new Date() };
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// src/server/brandImport.js
|
|
793
|
+
import dns from "dns";
|
|
794
|
+
import http from "http";
|
|
795
|
+
import https from "https";
|
|
796
|
+
import net from "net";
|
|
797
|
+
import zlib from "zlib";
|
|
798
|
+
var BRAND_IMPORT_LIMITS = {
|
|
799
|
+
pageBytes: 1.5 * 1024 * 1024,
|
|
800
|
+
cssBytes: 400 * 1024,
|
|
801
|
+
logoBytes: MAX_IMAGE_BYTES,
|
|
802
|
+
stylesheets: 4,
|
|
803
|
+
logos: 5,
|
|
804
|
+
redirects: 4,
|
|
805
|
+
timeoutMs: 8e3
|
|
806
|
+
};
|
|
807
|
+
var USER_AGENT = "BestEmailBuilder-BrandReader/1.0 (+https://github.com/SergeMiro/best-email-builder)";
|
|
808
|
+
var BrandImportError = class extends Error {
|
|
809
|
+
/** @param {BrandImportCode} code @param {string} [detail] */
|
|
810
|
+
constructor(code, detail = "") {
|
|
811
|
+
super(detail ? `${code}: ${detail}` : code);
|
|
812
|
+
this.name = "BrandImportError";
|
|
813
|
+
this.code = code;
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
var blocked = new net.BlockList();
|
|
817
|
+
for (
|
|
818
|
+
const [network, prefix] of
|
|
819
|
+
/** @type {[string, number][]} */
|
|
820
|
+
[
|
|
821
|
+
["0.0.0.0", 8],
|
|
822
|
+
// "this network"
|
|
823
|
+
["10.0.0.0", 8],
|
|
824
|
+
// private
|
|
825
|
+
["100.64.0.0", 10],
|
|
826
|
+
// carrier-grade NAT
|
|
827
|
+
["127.0.0.0", 8],
|
|
828
|
+
// loopback
|
|
829
|
+
["169.254.0.0", 16],
|
|
830
|
+
// link-local, cloud metadata
|
|
831
|
+
["172.16.0.0", 12],
|
|
832
|
+
// private
|
|
833
|
+
["192.0.0.0", 24],
|
|
834
|
+
// IETF protocol assignments
|
|
835
|
+
["192.0.2.0", 24],
|
|
836
|
+
// documentation
|
|
837
|
+
["192.88.99.0", 24],
|
|
838
|
+
// 6to4 relay
|
|
839
|
+
["192.168.0.0", 16],
|
|
840
|
+
// private
|
|
841
|
+
["198.18.0.0", 15],
|
|
842
|
+
// benchmarking
|
|
843
|
+
["198.51.100.0", 24],
|
|
844
|
+
// documentation
|
|
845
|
+
["203.0.113.0", 24],
|
|
846
|
+
// documentation
|
|
847
|
+
["224.0.0.0", 4],
|
|
848
|
+
// multicast
|
|
849
|
+
["240.0.0.0", 4]
|
|
850
|
+
// reserved, broadcast
|
|
851
|
+
]
|
|
852
|
+
) {
|
|
853
|
+
blocked.addSubnet(network, prefix, "ipv4");
|
|
854
|
+
}
|
|
855
|
+
for (
|
|
856
|
+
const [network, prefix] of
|
|
857
|
+
/** @type {[string, number][]} */
|
|
858
|
+
[
|
|
859
|
+
["::", 128],
|
|
860
|
+
// unspecified
|
|
861
|
+
["::1", 128],
|
|
862
|
+
// loopback
|
|
863
|
+
["::", 96],
|
|
864
|
+
// IPv4-compatible (deprecated)
|
|
865
|
+
["100::", 64],
|
|
866
|
+
// discard
|
|
867
|
+
["2001::", 32],
|
|
868
|
+
// Teredo
|
|
869
|
+
["2001:db8::", 32],
|
|
870
|
+
// documentation
|
|
871
|
+
["fc00::", 7],
|
|
872
|
+
// unique local
|
|
873
|
+
["fe80::", 10],
|
|
874
|
+
// link-local
|
|
875
|
+
["fec0::", 10],
|
|
876
|
+
// site-local (deprecated)
|
|
877
|
+
["ff00::", 8]
|
|
878
|
+
// multicast
|
|
879
|
+
]
|
|
880
|
+
) {
|
|
881
|
+
blocked.addSubnet(network, prefix, "ipv6");
|
|
882
|
+
}
|
|
883
|
+
function embeddedIpv4(address) {
|
|
884
|
+
const lower = address.toLowerCase();
|
|
885
|
+
const dotted = lower.match(/^(?:::ffff:(?:0:)?|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/);
|
|
886
|
+
if (dotted) return dotted[1];
|
|
887
|
+
const groups = expandIpv6(lower);
|
|
888
|
+
if (!groups) return "";
|
|
889
|
+
const toV4 = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
890
|
+
if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 65535) return toV4(groups[6], groups[7]);
|
|
891
|
+
if (groups[0] === 100 && groups[1] === 65435 && groups.slice(2, 6).every((g) => g === 0)) return toV4(groups[6], groups[7]);
|
|
892
|
+
if (groups[0] === 8194) return toV4(groups[1], groups[2]);
|
|
893
|
+
return "";
|
|
894
|
+
}
|
|
895
|
+
function expandIpv6(address) {
|
|
896
|
+
if (!net.isIPv6(address)) return null;
|
|
897
|
+
let text2 = address.replace(/%.*$/, "");
|
|
898
|
+
const v4 = text2.match(/(\d+\.\d+\.\d+\.\d+)$/);
|
|
899
|
+
if (v4) {
|
|
900
|
+
const [a, b, c, d] = v4[1].split(".").map(Number);
|
|
901
|
+
text2 = text2.replace(v4[1], `${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`);
|
|
902
|
+
}
|
|
903
|
+
const [left, right = ""] = text2.split("::");
|
|
904
|
+
const head = left ? left.split(":") : [];
|
|
905
|
+
const tail = text2.includes("::") && right ? right.split(":") : [];
|
|
906
|
+
const fill = text2.includes("::") ? Array(8 - head.length - tail.length).fill("0") : [];
|
|
907
|
+
const groups = [...head, ...fill, ...tail].map((g) => parseInt(g || "0", 16));
|
|
908
|
+
return groups.length === 8 ? groups : null;
|
|
909
|
+
}
|
|
910
|
+
function isPublicAddress(address) {
|
|
911
|
+
const ip = String(address ?? "").replace(/^\[|\]$/g, "");
|
|
912
|
+
const family = net.isIP(ip);
|
|
913
|
+
if (family === 4) return !blocked.check(ip, "ipv4");
|
|
914
|
+
if (family === 6) {
|
|
915
|
+
const v4 = embeddedIpv4(ip);
|
|
916
|
+
if (v4) return !blocked.check(v4, "ipv4");
|
|
917
|
+
return !blocked.check(ip.replace(/%.*$/, ""), "ipv6");
|
|
918
|
+
}
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
function guardedLookup(allow) {
|
|
922
|
+
const lookup = (hostname, options, callback) => {
|
|
923
|
+
if (typeof options === "function") {
|
|
924
|
+
callback = options;
|
|
925
|
+
options = {};
|
|
926
|
+
}
|
|
927
|
+
dns.lookup(hostname, { ...options, all: true }, (error, addresses) => {
|
|
928
|
+
if (error) return callback(error);
|
|
929
|
+
const list = (
|
|
930
|
+
/** @type {dns.LookupAddress[]} */
|
|
931
|
+
/** @type {unknown} */
|
|
932
|
+
addresses
|
|
933
|
+
);
|
|
934
|
+
if (!list.length || list.some((entry) => !allow(entry.address))) {
|
|
935
|
+
return callback(new BrandImportError("PRIVATE_ADDRESS", hostname));
|
|
936
|
+
}
|
|
937
|
+
if (options.all) return callback(null, list);
|
|
938
|
+
return callback(null, list[0].address, list[0].family);
|
|
939
|
+
});
|
|
940
|
+
};
|
|
941
|
+
return lookup;
|
|
942
|
+
}
|
|
943
|
+
var isWebPort = (port) => port === 80 || port === 443;
|
|
944
|
+
function checkUrl(input, allow, allowPort = isWebPort) {
|
|
945
|
+
let url;
|
|
946
|
+
try {
|
|
947
|
+
url = new URL(String(input));
|
|
948
|
+
} catch {
|
|
949
|
+
throw new BrandImportError("BAD_URL");
|
|
950
|
+
}
|
|
951
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new BrandImportError("BAD_URL", url.protocol);
|
|
952
|
+
if (url.username || url.password) throw new BrandImportError("BAD_URL", "credentials");
|
|
953
|
+
if (url.port && !allowPort(Number(url.port))) throw new BrandImportError("BAD_URL", `port ${url.port}`);
|
|
954
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
955
|
+
if (!host) throw new BrandImportError("BAD_URL");
|
|
956
|
+
if (net.isIP(host) && !allow(host)) throw new BrandImportError("PRIVATE_ADDRESS", host);
|
|
957
|
+
return url;
|
|
958
|
+
}
|
|
959
|
+
function siteUrl(input) {
|
|
960
|
+
const text2 = String(input ?? "").trim();
|
|
961
|
+
if (!text2 || text2.length > 500 || /\s/.test(text2)) throw new BrandImportError("BAD_URL");
|
|
962
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text2) ? text2 : `https://${text2}`;
|
|
963
|
+
let url;
|
|
964
|
+
try {
|
|
965
|
+
url = new URL(withScheme);
|
|
966
|
+
} catch {
|
|
967
|
+
throw new BrandImportError("BAD_URL");
|
|
968
|
+
}
|
|
969
|
+
if (!/^https?:$/.test(url.protocol) || !url.hostname.includes(".") && !net.isIP(url.hostname.replace(/^\[|\]$/g, ""))) {
|
|
970
|
+
throw new BrandImportError("BAD_URL");
|
|
971
|
+
}
|
|
972
|
+
return url.href;
|
|
973
|
+
}
|
|
974
|
+
async function safeGet(address, { allow, allowPort = isWebPort, signal, maxBytes, truncate = false, accept = "*/*", redirects = BRAND_IMPORT_LIMITS.redirects }) {
|
|
975
|
+
let url = checkUrl(address, allow, allowPort);
|
|
976
|
+
for (let hop = 0; ; hop += 1) {
|
|
977
|
+
const response = await requestOnce(url, { allow, signal, maxBytes, truncate, accept });
|
|
978
|
+
if (response.location === null) return { bytes: response.bytes, url: url.href, contentType: response.contentType };
|
|
979
|
+
if (hop >= redirects) throw new BrandImportError("TOO_MANY_REDIRECTS");
|
|
980
|
+
url = checkUrl(new URL(response.location, url), allow, allowPort);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
function requestOnce(url, { allow, signal, maxBytes, truncate, accept }) {
|
|
984
|
+
return new Promise((resolve, reject) => {
|
|
985
|
+
if (signal.aborted) return reject(abortReason(signal));
|
|
986
|
+
const client = url.protocol === "https:" ? https : http;
|
|
987
|
+
const request = client.request(url, {
|
|
988
|
+
method: "GET",
|
|
989
|
+
agent: false,
|
|
990
|
+
lookup: guardedLookup(allow),
|
|
991
|
+
signal,
|
|
992
|
+
headers: {
|
|
993
|
+
"User-Agent": USER_AGENT,
|
|
994
|
+
Accept: accept,
|
|
995
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
996
|
+
"Accept-Language": "en;q=0.9, *;q=0.5"
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
request.on("error", (error) => reject(toImportError(error, signal)));
|
|
1000
|
+
request.on("response", (response) => {
|
|
1001
|
+
const status = response.statusCode || 0;
|
|
1002
|
+
if (status >= 300 && status < 400 && response.headers.location) {
|
|
1003
|
+
response.resume();
|
|
1004
|
+
request.destroy();
|
|
1005
|
+
return resolve({ location: response.headers.location, bytes: Buffer.alloc(0), contentType: "" });
|
|
1006
|
+
}
|
|
1007
|
+
if (status < 200 || status >= 300) {
|
|
1008
|
+
response.resume();
|
|
1009
|
+
request.destroy();
|
|
1010
|
+
return reject(new BrandImportError("HTTP_ERROR", String(status)));
|
|
1011
|
+
}
|
|
1012
|
+
const encoding = String(response.headers["content-encoding"] || "").toLowerCase().trim();
|
|
1013
|
+
let body = response;
|
|
1014
|
+
if (encoding === "gzip" || encoding === "x-gzip") body = response.pipe(zlib.createGunzip());
|
|
1015
|
+
else if (encoding === "deflate") body = response.pipe(zlib.createInflate());
|
|
1016
|
+
else if (encoding === "br") body = response.pipe(zlib.createBrotliDecompress());
|
|
1017
|
+
const chunks = [];
|
|
1018
|
+
let size = 0;
|
|
1019
|
+
let done = false;
|
|
1020
|
+
const finish = (error) => {
|
|
1021
|
+
if (done) return;
|
|
1022
|
+
done = true;
|
|
1023
|
+
request.destroy();
|
|
1024
|
+
if (error) reject(error);
|
|
1025
|
+
else resolve({ location: null, bytes: Buffer.concat(chunks), contentType: String(response.headers["content-type"] || "") });
|
|
1026
|
+
};
|
|
1027
|
+
body.on("data", (chunk) => {
|
|
1028
|
+
if (done) return;
|
|
1029
|
+
if (size + chunk.length > maxBytes) {
|
|
1030
|
+
if (!truncate) return finish(new BrandImportError("TOO_LARGE"));
|
|
1031
|
+
chunks.push(chunk.subarray(0, maxBytes - size));
|
|
1032
|
+
size = maxBytes;
|
|
1033
|
+
return finish(null);
|
|
1034
|
+
}
|
|
1035
|
+
chunks.push(chunk);
|
|
1036
|
+
size += chunk.length;
|
|
1037
|
+
});
|
|
1038
|
+
body.on("end", () => finish(null));
|
|
1039
|
+
body.on("error", (error) => finish(toImportError(error, signal)));
|
|
1040
|
+
response.on("error", (error) => finish(toImportError(error, signal)));
|
|
1041
|
+
response.on("close", () => {
|
|
1042
|
+
if (!response.complete) finish(toImportError(new Error("connection closed"), signal));
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
request.end();
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
var abortReason = (signal) => signal.reason?.name === "TimeoutError" ? new BrandImportError("TIMEOUT") : Object.assign(new Error("Aborted"), { name: "AbortError" });
|
|
1049
|
+
function toImportError(error, signal) {
|
|
1050
|
+
if (error instanceof BrandImportError) return error;
|
|
1051
|
+
if (error?.cause instanceof BrandImportError) return error.cause;
|
|
1052
|
+
if (signal.aborted) return abortReason(signal);
|
|
1053
|
+
return new BrandImportError("UNREACHABLE", error?.code || error?.message || "");
|
|
1054
|
+
}
|
|
1055
|
+
function textOf(bytes, contentType) {
|
|
1056
|
+
const sniffed = bytes.subarray(0, 2048).toString("latin1").match(/<meta[^>]+charset\s*=\s*["']?([\w-]+)/i)?.[1];
|
|
1057
|
+
const charset = contentType.match(/charset\s*=\s*["']?([\w-]+)/i)?.[1] || sniffed || "utf-8";
|
|
1058
|
+
try {
|
|
1059
|
+
return new TextDecoder(charset).decode(bytes);
|
|
1060
|
+
} catch {
|
|
1061
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
function createBrandImporter({
|
|
1065
|
+
timeoutMs = BRAND_IMPORT_LIMITS.timeoutMs,
|
|
1066
|
+
allowAddress = isPublicAddress,
|
|
1067
|
+
allowPort = isWebPort
|
|
1068
|
+
} = {}) {
|
|
1069
|
+
async function importBrand(input, { signal } = {}) {
|
|
1070
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
1071
|
+
const all = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
1072
|
+
const get = (address, options) => safeGet(address, { allow: allowAddress, allowPort, signal: all, ...options });
|
|
1073
|
+
const page = await get(siteUrl(input), {
|
|
1074
|
+
maxBytes: BRAND_IMPORT_LIMITS.pageBytes,
|
|
1075
|
+
truncate: true,
|
|
1076
|
+
accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5"
|
|
1077
|
+
});
|
|
1078
|
+
if (page.contentType && !/html|xml/i.test(page.contentType)) throw new BrandImportError("NOT_HTML", page.contentType);
|
|
1079
|
+
const reading = readPage(textOf(page.bytes, page.contentType), page.url);
|
|
1080
|
+
const notes = [];
|
|
1081
|
+
const [sheets, logo] = await Promise.all([
|
|
1082
|
+
Promise.all(
|
|
1083
|
+
reading.stylesheets.slice(0, BRAND_IMPORT_LIMITS.stylesheets).map(
|
|
1084
|
+
(href) => get(href, { maxBytes: BRAND_IMPORT_LIMITS.cssBytes, truncate: true, accept: "text/css,*/*;q=0.1" }).then((sheet) => textOf(sheet.bytes, sheet.contentType)).catch((error) => {
|
|
1085
|
+
if (error?.name === "AbortError") throw error;
|
|
1086
|
+
return "";
|
|
1087
|
+
})
|
|
1088
|
+
)
|
|
1089
|
+
),
|
|
1090
|
+
(async () => {
|
|
1091
|
+
let unsupported = false;
|
|
1092
|
+
for (const candidate of reading.logos.slice(0, BRAND_IMPORT_LIMITS.logos)) {
|
|
1093
|
+
if (/\.(svg|webp|ico)(\?|#|$)/i.test(new URL(candidate.url).pathname)) {
|
|
1094
|
+
unsupported ||= candidate.from !== "icon";
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
let file;
|
|
1098
|
+
try {
|
|
1099
|
+
file = await get(candidate.url, { maxBytes: BRAND_IMPORT_LIMITS.logoBytes, accept: "image/png,image/jpeg,image/gif;q=0.9,*/*;q=0.1" });
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
if (
|
|
1102
|
+
/** @type {any} */
|
|
1103
|
+
error?.name === "AbortError"
|
|
1104
|
+
) throw error;
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
const bytes = new Uint8Array(file.bytes);
|
|
1108
|
+
const type = detectImage(bytes);
|
|
1109
|
+
const size = type && imageSize(bytes);
|
|
1110
|
+
if (!type || !size) {
|
|
1111
|
+
unsupported ||= candidate.from !== "icon" && /svg|webp/i.test(file.contentType);
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
if (size.width < 32 && size.height < 32) continue;
|
|
1115
|
+
if (candidate.from === "icon") notes.push("LOGO_ICON");
|
|
1116
|
+
return { bytes, ...size, ...type };
|
|
1117
|
+
}
|
|
1118
|
+
notes.push(unsupported ? "LOGO_UNSUPPORTED" : "LOGO_NOT_FOUND");
|
|
1119
|
+
return null;
|
|
1120
|
+
})()
|
|
1121
|
+
]);
|
|
1122
|
+
const style = brandFromCss({ css: [reading.css, ...sheets].join("\n"), themeColor: reading.themeColor });
|
|
1123
|
+
if (!style.primary) notes.push("COLORS_NOT_FOUND");
|
|
1124
|
+
const brand = cleanBrand({ name: reading.name, website: page.url, ...style });
|
|
1125
|
+
return { brand, logo: logo && { ...logo, shownWidth: logoWidthFor(logo) }, notes };
|
|
1126
|
+
}
|
|
1127
|
+
return { import: importBrand };
|
|
1128
|
+
}
|
|
1129
|
+
|
|
932
1130
|
// src/server/handler.js
|
|
933
1131
|
var MAX_JSON_BYTES = 3 * 1024 * 1024;
|
|
934
1132
|
var HOUR = 60 * 60 * 1e3;
|
|
1133
|
+
var isGenerationError = (error) => error?.name === "EmailGenerationError" && Number.isInteger(error.status) && typeof error.code === "string";
|
|
935
1134
|
var json = (data, status = 200) => new Response(JSON.stringify(data), {
|
|
936
1135
|
status,
|
|
937
1136
|
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }
|
|
@@ -959,6 +1158,8 @@ function createEmailBuilderHandler(options) {
|
|
|
959
1158
|
basePath = "/api/email-builder",
|
|
960
1159
|
publicUrl = null,
|
|
961
1160
|
transport = null,
|
|
1161
|
+
generator = null,
|
|
1162
|
+
brandImporter = null,
|
|
962
1163
|
locale = "en",
|
|
963
1164
|
messages,
|
|
964
1165
|
lang = locale.slice(0, 2),
|
|
@@ -976,12 +1177,17 @@ function createEmailBuilderHandler(options) {
|
|
|
976
1177
|
const base = basePath.replace(/\/+$/, "");
|
|
977
1178
|
const assetsPath = (options.assetsPath || `${base}/assets/`).replace(/\/*$/, "/");
|
|
978
1179
|
const variables = defineVariables(options.variables || DEFAULT_VARIABLES);
|
|
979
|
-
const t = translatorFor(locale, messages);
|
|
1180
|
+
const t = translatorFor(locale, { ...messagesOf(generator?.messages, locale), ...messages });
|
|
980
1181
|
const templates = createTemplateService({ repository: templateRepository, t, lang, maxVersions });
|
|
981
1182
|
const assets = createAssetService({ repository: assetRepository, publicPath: assetsPath, t });
|
|
982
1183
|
const customVariables = createVariableService({ repository: options.variableStore || null, system: variables, t });
|
|
1184
|
+
const brand = createBrandService({ repository: options.brandStore || null, t });
|
|
983
1185
|
const testLimiter = createLimiter(rateLimits.testPerHour ?? 20);
|
|
984
1186
|
const uploadLimiter = createLimiter(rateLimits.uploadsPerHour ?? 100);
|
|
1187
|
+
const generateLimiter = createLimiter(rateLimits.generatePerHour ?? 30);
|
|
1188
|
+
const rewriteLimiter = createLimiter(rateLimits.rewritePerHour ?? 200);
|
|
1189
|
+
const subjectsLimiter = createLimiter(rateLimits.subjectsPerHour ?? 100);
|
|
1190
|
+
const brandImportLimiter = createLimiter(rateLimits.brandImportsPerHour ?? 20);
|
|
985
1191
|
async function userFor(request, action) {
|
|
986
1192
|
const user = await authorize(request, { action });
|
|
987
1193
|
if (!user) throw new HttpError(401, t("Authentication required"), "UNAUTHORIZED");
|
|
@@ -991,6 +1197,7 @@ function createEmailBuilderHandler(options) {
|
|
|
991
1197
|
);
|
|
992
1198
|
}
|
|
993
1199
|
const userKey = (user) => String(user.id ?? user.name ?? "anonymous");
|
|
1200
|
+
const languageOr = (value) => typeof value === "string" && /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i.test(value) ? value : lang;
|
|
994
1201
|
async function readJson(request) {
|
|
995
1202
|
const length = Number(request.headers.get("content-length") || 0);
|
|
996
1203
|
if (length > MAX_JSON_BYTES) throw new HttpError(413, t("Request too large"), "TOO_LARGE");
|
|
@@ -1002,6 +1209,16 @@ function createEmailBuilderHandler(options) {
|
|
|
1002
1209
|
throw new HttpError(400, t("Invalid JSON"), "INVALID_JSON");
|
|
1003
1210
|
}
|
|
1004
1211
|
}
|
|
1212
|
+
async function askModel(request, call) {
|
|
1213
|
+
try {
|
|
1214
|
+
return json({ data: await call() });
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
if (request.signal?.aborted) return json({ error: t("Cancelled"), code: "CANCELLED" }, 499);
|
|
1217
|
+
if (isGenerationError(error)) throw new HttpError(error.status, t(error.message), error.code);
|
|
1218
|
+
onError(error, request);
|
|
1219
|
+
throw new HttpError(502, t("The AI service did not answer \u2014 try again in a moment."), "MODEL_ERROR");
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1005
1222
|
async function serveAsset(key) {
|
|
1006
1223
|
const asset = await assets.get(key);
|
|
1007
1224
|
if (!asset) return new Response("Not found", { status: 404, headers: { "Content-Type": "text/plain" } });
|
|
@@ -1099,6 +1316,40 @@ function createEmailBuilderHandler(options) {
|
|
|
1099
1316
|
});
|
|
1100
1317
|
return json({ data: result });
|
|
1101
1318
|
}
|
|
1319
|
+
if (section === "generate" && !id && method === "POST") {
|
|
1320
|
+
const user = await userFor(request, "write");
|
|
1321
|
+
const generate = generator?.generate;
|
|
1322
|
+
if (typeof generate !== "function") throw new HttpError(501, t("Email generation is not configured"), "NO_GENERATOR");
|
|
1323
|
+
if (!generateLimiter(userKey(user))) {
|
|
1324
|
+
throw new HttpError(429, t("Too many generated emails \u2014 try again later"), "RATE_LIMITED");
|
|
1325
|
+
}
|
|
1326
|
+
const body = await readJson(request);
|
|
1327
|
+
const catalog = await customVariables.catalog();
|
|
1328
|
+
return askModel(
|
|
1329
|
+
request,
|
|
1330
|
+
() => generate.call(generator, { ...body, language: languageOr(body?.language), variables: catalog }, { signal: request.signal })
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
if (section === "rewrite" && !id && method === "POST") {
|
|
1334
|
+
const user = await userFor(request, "write");
|
|
1335
|
+
const rewrite = generator?.rewrite;
|
|
1336
|
+
if (typeof rewrite !== "function") throw new HttpError(501, t("Text rewriting is not configured"), "NO_GENERATOR");
|
|
1337
|
+
if (!rewriteLimiter(userKey(user))) throw new HttpError(429, t("Too many rewrites \u2014 try again later"), "RATE_LIMITED");
|
|
1338
|
+
const body = await readJson(request);
|
|
1339
|
+
return askModel(request, () => rewrite(body, { signal: request.signal }));
|
|
1340
|
+
}
|
|
1341
|
+
if (section === "subjects" && !id && method === "POST") {
|
|
1342
|
+
const user = await userFor(request, "write");
|
|
1343
|
+
const suggestSubjects = generator?.suggestSubjects;
|
|
1344
|
+
if (typeof suggestSubjects !== "function") throw new HttpError(501, t("Subject ideas are not configured"), "NO_GENERATOR");
|
|
1345
|
+
if (!subjectsLimiter(userKey(user))) throw new HttpError(429, t("Too many subject ideas \u2014 try again later"), "RATE_LIMITED");
|
|
1346
|
+
const body = await readJson(request);
|
|
1347
|
+
const catalog = await customVariables.catalog();
|
|
1348
|
+
return askModel(
|
|
1349
|
+
request,
|
|
1350
|
+
() => suggestSubjects({ ...body, language: languageOr(body?.language), variables: catalog }, { signal: request.signal })
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1102
1353
|
if (section === "assets" && !id && method === "POST") {
|
|
1103
1354
|
const user = await userFor(request, "write");
|
|
1104
1355
|
if (!uploadLimiter(userKey(user))) throw new HttpError(429, t("Too many uploads \u2014 try again later"), "RATE_LIMITED");
|
|
@@ -1136,15 +1387,53 @@ function createEmailBuilderHandler(options) {
|
|
|
1136
1387
|
return json({ data: await customVariables.remove(id) });
|
|
1137
1388
|
}
|
|
1138
1389
|
}
|
|
1390
|
+
if (section === "brand" && !sub) {
|
|
1391
|
+
if (!id && method === "GET") {
|
|
1392
|
+
await userFor(request, "read");
|
|
1393
|
+
return json({ data: await brand.get() });
|
|
1394
|
+
}
|
|
1395
|
+
if (!id && method === "PUT") {
|
|
1396
|
+
const user = await userFor(request, "write");
|
|
1397
|
+
return json({ data: await brand.save(await readJson(request), user) });
|
|
1398
|
+
}
|
|
1399
|
+
if (id === "import" && method === "POST") {
|
|
1400
|
+
const user = await userFor(request, "write");
|
|
1401
|
+
if (!brandImporter) throw new HttpError(501, t("Reading a brand from a website is not configured"), "NO_BRAND_IMPORTER");
|
|
1402
|
+
if (!brandImportLimiter(userKey(user))) {
|
|
1403
|
+
throw new HttpError(429, t("Too many websites read \u2014 try again later"), "RATE_LIMITED");
|
|
1404
|
+
}
|
|
1405
|
+
const body = await readJson(request);
|
|
1406
|
+
return json({ data: await importBrand(request, body?.url, user) });
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1139
1409
|
throw new HttpError(404, t("Route not found"), "ROUTE_NOT_FOUND");
|
|
1140
1410
|
}
|
|
1411
|
+
async function importBrand(request, address, user) {
|
|
1412
|
+
let result;
|
|
1413
|
+
try {
|
|
1414
|
+
result = await /** @type {NonNullable<typeof brandImporter>} */
|
|
1415
|
+
brandImporter.import(String(address ?? ""), { signal: request.signal });
|
|
1416
|
+
} catch (error) {
|
|
1417
|
+
if (request.signal?.aborted) throw new HttpError(499, t("Cancelled"), "CANCELLED");
|
|
1418
|
+
if (!(error instanceof BrandImportError)) throw error;
|
|
1419
|
+
const [status, message] = BRAND_IMPORT_ERRORS[error.code] || BRAND_IMPORT_ERRORS.UNREACHABLE;
|
|
1420
|
+
throw new HttpError(status, t(message), `BRAND_${error.code}`);
|
|
1421
|
+
}
|
|
1422
|
+
const found = { ...result.brand };
|
|
1423
|
+
if (result.logo) {
|
|
1424
|
+
const { path } = await assets.upload({ bytes: result.logo.bytes, name: `logo.${result.logo.ext}`, user });
|
|
1425
|
+
found.logoUrl = path;
|
|
1426
|
+
found.logoWidth = result.logo.shownWidth;
|
|
1427
|
+
}
|
|
1428
|
+
return { brand: found, notes: result.notes };
|
|
1429
|
+
}
|
|
1141
1430
|
async function handle(request) {
|
|
1142
1431
|
try {
|
|
1143
1432
|
const response = await route(request);
|
|
1144
1433
|
return response || json({ error: t("Route not found"), code: "ROUTE_NOT_FOUND" }, 404);
|
|
1145
1434
|
} catch (error) {
|
|
1146
1435
|
const status = statusOf(error);
|
|
1147
|
-
if (status >= 500 &&
|
|
1436
|
+
if (status >= 500 && !(error instanceof HttpError)) {
|
|
1148
1437
|
onError(error, request);
|
|
1149
1438
|
return json({ error: t("Internal error"), code: "INTERNAL" }, 500);
|
|
1150
1439
|
}
|
|
@@ -1205,6 +1494,8 @@ function toNodeListener(handler, { origin } = {}) {
|
|
|
1205
1494
|
};
|
|
1206
1495
|
}
|
|
1207
1496
|
export {
|
|
1497
|
+
BRAND_IMPORT_LIMITS,
|
|
1498
|
+
BrandImportError,
|
|
1208
1499
|
DEFAULT_ASSETS_PATH,
|
|
1209
1500
|
DEFAULT_VARIABLES,
|
|
1210
1501
|
EMAIL_BUILDER_VERSION,
|
|
@@ -1217,17 +1508,22 @@ export {
|
|
|
1217
1508
|
MAX_ACTIVE_VERSIONS,
|
|
1218
1509
|
MAX_TEST_RECIPIENTS,
|
|
1219
1510
|
absoluteAssetUrls,
|
|
1511
|
+
applyBrand,
|
|
1220
1512
|
applyServiceLinks,
|
|
1221
1513
|
applyVariables,
|
|
1222
1514
|
applyVariablesToText,
|
|
1223
1515
|
assertValidMjml,
|
|
1224
1516
|
assertValidProject,
|
|
1517
|
+
cleanBrand,
|
|
1225
1518
|
compileMjml,
|
|
1226
1519
|
compileTemplate,
|
|
1227
1520
|
createAssetService,
|
|
1521
|
+
createBrandImporter,
|
|
1522
|
+
createBrandService,
|
|
1228
1523
|
createDocument,
|
|
1229
1524
|
createEmailBuilderHandler,
|
|
1230
1525
|
createMemoryAssetRepository,
|
|
1526
|
+
createMemoryBrandRepository,
|
|
1231
1527
|
createMemoryTemplateRepository,
|
|
1232
1528
|
createMemoryVariableRepository,
|
|
1233
1529
|
createTemplateService,
|
|
@@ -1237,9 +1533,11 @@ export {
|
|
|
1237
1533
|
htmlToText,
|
|
1238
1534
|
inspectAsset,
|
|
1239
1535
|
isAssetPath,
|
|
1536
|
+
isPublicAddress,
|
|
1240
1537
|
keyFromLabel,
|
|
1241
1538
|
mapVariables,
|
|
1242
1539
|
mergeVariables,
|
|
1540
|
+
messagesOf,
|
|
1243
1541
|
personalize,
|
|
1244
1542
|
prepareMjml,
|
|
1245
1543
|
qualityReport,
|
|
@@ -1248,6 +1546,7 @@ export {
|
|
|
1248
1546
|
sampleValues,
|
|
1249
1547
|
sendTestEmail,
|
|
1250
1548
|
serializeDocument,
|
|
1549
|
+
simulateDarkMode,
|
|
1251
1550
|
toNodeListener,
|
|
1252
1551
|
tokenOf,
|
|
1253
1552
|
translatorFor,
|