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