best-email-builder 0.3.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +214 -19
  3. package/dist/builder.js +4 -4
  4. package/dist/{chunk-ZSUHOCCE.js → chunk-AT73Y7JA.js} +159 -83
  5. package/dist/chunk-AT73Y7JA.js.map +1 -0
  6. package/dist/chunk-HHLOE6OZ.js +1682 -0
  7. package/dist/chunk-HHLOE6OZ.js.map +1 -0
  8. package/dist/chunk-JNHFZXDL.js +1776 -0
  9. package/dist/chunk-JNHFZXDL.js.map +1 -0
  10. package/dist/chunk-MY6WPDT4.js +6110 -0
  11. package/dist/chunk-MY6WPDT4.js.map +1 -0
  12. package/dist/{chunk-JOC752I3.js → chunk-NAEGU6PR.js} +13 -4
  13. package/dist/{chunk-JOC752I3.js.map → chunk-NAEGU6PR.js.map} +1 -1
  14. package/dist/{chunk-BVOVAEEJ.js → chunk-TGDZ4NFI.js} +44 -3
  15. package/dist/chunk-TGDZ4NFI.js.map +1 -0
  16. package/dist/index.js +45 -8
  17. package/dist/index.js.map +1 -1
  18. package/dist/library.js +3 -3
  19. package/dist/server.js +544 -204
  20. package/dist/server.js.map +1 -1
  21. package/dist/styles.css +1 -1
  22. package/dist/types/blocks/shared.d.ts +20 -0
  23. package/dist/types/core/EmailEditor.d.ts +72 -19
  24. package/dist/types/core/constants.d.ts +1 -1
  25. package/dist/types/core/mjmlDefaults.d.ts +13 -0
  26. package/dist/types/core/scrollbars.d.ts +1 -1
  27. package/dist/types/defaultEditor.d.ts +4 -1
  28. package/dist/types/engine/NativeAdapter.d.ts +28 -0
  29. package/dist/types/engine/canvas/CanvasView.d.ts +88 -0
  30. package/dist/types/engine/canvas/canvasStyles.d.ts +3 -0
  31. package/dist/types/engine/canvas/icons.d.ts +14 -0
  32. package/dist/types/engine/canvas/morph.d.ts +21 -0
  33. package/dist/types/engine/canvasMjml.d.ts +30 -0
  34. package/dist/types/engine/mjml.d.ts +31 -0
  35. package/dist/types/engine/model.d.ts +130 -0
  36. package/dist/types/engine/placement.d.ts +69 -0
  37. package/dist/types/engine/richText.d.ts +15 -0
  38. package/dist/types/engine/unwrapModule.d.ts +29 -0
  39. package/dist/types/features/brand/BrandButton.d.ts +4 -0
  40. package/dist/types/features/brand/brand.d.ts +108 -0
  41. package/dist/types/features/brand/extract.d.ts +61 -0
  42. package/dist/types/features/brand/index.d.ts +2 -0
  43. package/dist/types/features/brand/messages.d.ts +13 -0
  44. package/dist/types/features/brand/useBrand.d.ts +22 -0
  45. package/dist/types/features/generate/GenerateButton.d.ts +3 -0
  46. package/dist/types/features/generate/RewriteText.d.ts +5 -0
  47. package/dist/types/features/generate/SubjectIdeas.d.ts +16 -0
  48. package/dist/types/features/generate/chip.d.ts +1 -0
  49. package/dist/types/features/generate/claude.d.ts +21 -0
  50. package/dist/types/features/generate/draft.d.ts +122 -0
  51. package/dist/types/features/generate/generator.d.ts +118 -0
  52. package/dist/types/features/generate/index.d.ts +2 -0
  53. package/dist/types/features/generate/prompt.d.ts +76 -0
  54. package/dist/types/features/generate/purposes.d.ts +48 -0
  55. package/dist/types/features/generate/rewrite.d.ts +83 -0
  56. package/dist/types/features/generate/subjects.d.ts +83 -0
  57. package/dist/types/features/preview/PreviewButton.d.ts +1 -1
  58. package/dist/types/features/preview/darkMode.d.ts +48 -0
  59. package/dist/types/index.d.ts +10 -1
  60. package/dist/types/server/brand.d.ts +32 -0
  61. package/dist/types/server/brandImport.d.ts +93 -0
  62. package/dist/types/server/compileMjml.d.ts +2 -0
  63. package/dist/types/server/handler.d.ts +25 -0
  64. package/dist/types/server/index.d.ts +11 -0
  65. package/dist/types/storage/client.d.ts +75 -3
  66. package/dist/types/ui/fields.d.ts +2 -1
  67. package/dist/types/ui/primitives/button.d.ts +1 -1
  68. package/dist/types/ui/primitives/color-picker.d.ts +2 -1
  69. package/dist/types/ui/useLayoutWidth.d.ts +1 -1
  70. package/package.json +13 -7
  71. package/dist/chunk-3M36VVXS.js +0 -665
  72. package/dist/chunk-3M36VVXS.js.map +0 -1
  73. package/dist/chunk-57CTQNTK.js +0 -56
  74. package/dist/chunk-57CTQNTK.js.map +0 -1
  75. package/dist/chunk-BVOVAEEJ.js.map +0 -1
  76. package/dist/chunk-ILRAQTHS.js +0 -3677
  77. package/dist/chunk-ILRAQTHS.js.map +0 -1
  78. package/dist/chunk-ZSUHOCCE.js.map +0 -1
  79. package/dist/types/adapters/grapesjs/GrapesJsAdapter.d.ts +0 -33
  80. package/dist/types/adapters/grapesjs/engineConfig.d.ts +0 -148
  81. package/dist/types/adapters/grapesjs/unwrapModule.d.ts +0 -18
package/dist/server.js CHANGED
@@ -1,17 +1,49 @@
1
1
  import {
2
+ DEFAULT_CLAUDE_MODEL,
2
3
  applyVariables,
3
4
  applyVariablesToText,
5
+ claudeEmailModel,
4
6
  mapVariables,
5
7
  sampleValues,
6
8
  unknownVariables,
7
9
  usedVariables
8
- } from "./chunk-BVOVAEEJ.js";
10
+ } from "./chunk-TGDZ4NFI.js";
9
11
  import {
12
+ BRAND_IMPORT_ERRORS,
13
+ EMAIL_DRAFT_SCHEMA,
14
+ EMAIL_PURPOSES,
15
+ EmailGenerationError,
16
+ GENERATION_ERRORS,
10
17
  IMAGE_ATTRIBUTES,
11
18
  LINK_ATTRIBUTES,
12
- decodeEntities,
13
- isSafeUrl
14
- } from "./chunk-57CTQNTK.js";
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";
15
47
  import {
16
48
  ASSET_KEY_PATTERN,
17
49
  DEFAULT_ASSETS_PATH,
@@ -22,7 +54,9 @@ import {
22
54
  LOCALES,
23
55
  MAX_IMAGE_BYTES,
24
56
  absoluteAssetUrls,
57
+ applyBrand,
25
58
  applyServiceLinks,
59
+ cleanBrand,
26
60
  createDocument,
27
61
  createTranslator,
28
62
  defineVariables,
@@ -33,10 +67,11 @@ import {
33
67
  mergeVariables,
34
68
  readDocument,
35
69
  serializeDocument,
70
+ simulateDarkMode,
36
71
  tokenOf,
37
72
  translatorFor,
38
73
  validateCustomVariable
39
- } from "./chunk-3M36VVXS.js";
74
+ } from "./chunk-HHLOE6OZ.js";
40
75
 
41
76
  // src/server/assets.js
42
77
  import { createHash } from "crypto";
@@ -130,198 +165,6 @@ function createMemoryAssetRepository() {
130
165
  };
131
166
  }
132
167
 
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
168
  // src/validation/quality.js
326
169
  var GMAIL_CLIP_BYTES = 102 * 1024;
327
170
  function count(source, pattern) {
@@ -361,13 +204,13 @@ function qualityReport({ mjml, html = "", subject = null }, { t = identity } = {
361
204
  message: t("Button without link ({count})", { count: noHref })
362
205
  });
363
206
  }
364
- const http = count(source, /\shref="http:\/\//gi);
365
- if (http) {
207
+ const http2 = count(source, /\shref="http:\/\//gi);
208
+ if (http2) {
366
209
  issues.push({
367
210
  code: "HTTP_LINK",
368
211
  level: "info",
369
- count: http,
370
- message: t("Insecure http link ({count})", { count: http })
212
+ count: http2,
213
+ message: t("Insecure http link ({count})", { count: http2 })
371
214
  });
372
215
  }
373
216
  if (subject !== null && !String(subject).trim()) {
@@ -410,6 +253,13 @@ function prepareMjml(mjml, { title = null, preheader = null, lang = "en" } = {})
410
253
 
411
254
  // src/server/compileMjml.js
412
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
+ }
413
263
  async function compileMjml(mjml) {
414
264
  const compile = (
415
265
  /** @type {any} */
@@ -424,7 +274,7 @@ async function compileMjml(mjml) {
424
274
  fonts: {}
425
275
  });
426
276
  return {
427
- html: String(result?.html || ""),
277
+ html: withClientMetas(String(result?.html || "")),
428
278
  warnings: (result?.errors || []).map((e) => ({
429
279
  line: e.line,
430
280
  message: String(e.formattedMessage || e.message || e),
@@ -564,7 +414,7 @@ function validateProject(raw, { t = identity } = {}) {
564
414
  for (
565
415
  const [key, pattern] of
566
416
  /** @type {[string, RegExp][]} */
567
- [["type", DANGEROUS_TYPES], ["tagName", DANGEROUS_TAGS]]
417
+ [["type", DANGEROUS_TYPES], ["tagName", DANGEROUS_TAGS], ["tag", DANGEROUS_TAGS]]
568
418
  ) {
569
419
  const value = node[key];
570
420
  if (typeof value === "string" && pattern.test(value)) {
@@ -929,6 +779,373 @@ function createMemoryVariableRepository() {
929
779
  };
930
780
  }
931
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
+
932
1149
  // src/server/handler.js
933
1150
  var MAX_JSON_BYTES = 3 * 1024 * 1024;
934
1151
  var HOUR = 60 * 60 * 1e3;
@@ -959,6 +1176,8 @@ function createEmailBuilderHandler(options) {
959
1176
  basePath = "/api/email-builder",
960
1177
  publicUrl = null,
961
1178
  transport = null,
1179
+ generator = null,
1180
+ brandImporter = null,
962
1181
  locale = "en",
963
1182
  messages,
964
1183
  lang = locale.slice(0, 2),
@@ -980,8 +1199,13 @@ function createEmailBuilderHandler(options) {
980
1199
  const templates = createTemplateService({ repository: templateRepository, t, lang, maxVersions });
981
1200
  const assets = createAssetService({ repository: assetRepository, publicPath: assetsPath, t });
982
1201
  const customVariables = createVariableService({ repository: options.variableStore || null, system: variables, t });
1202
+ const brand = createBrandService({ repository: options.brandStore || null, t });
983
1203
  const testLimiter = createLimiter(rateLimits.testPerHour ?? 20);
984
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);
985
1209
  async function userFor(request, action) {
986
1210
  const user = await authorize(request, { action });
987
1211
  if (!user) throw new HttpError(401, t("Authentication required"), "UNAUTHORIZED");
@@ -1002,6 +1226,16 @@ function createEmailBuilderHandler(options) {
1002
1226
  throw new HttpError(400, t("Invalid JSON"), "INVALID_JSON");
1003
1227
  }
1004
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
+ }
1005
1239
  async function serveAsset(key) {
1006
1240
  const asset = await assets.get(key);
1007
1241
  if (!asset) return new Response("Not found", { status: 404, headers: { "Content-Type": "text/plain" } });
@@ -1099,6 +1333,39 @@ function createEmailBuilderHandler(options) {
1099
1333
  });
1100
1334
  return json({ data: result });
1101
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
+ }
1102
1369
  if (section === "assets" && !id && method === "POST") {
1103
1370
  const user = await userFor(request, "write");
1104
1371
  if (!uploadLimiter(userKey(user))) throw new HttpError(429, t("Too many uploads \u2014 try again later"), "RATE_LIMITED");
@@ -1136,15 +1403,53 @@ function createEmailBuilderHandler(options) {
1136
1403
  return json({ data: await customVariables.remove(id) });
1137
1404
  }
1138
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
+ }
1139
1425
  throw new HttpError(404, t("Route not found"), "ROUTE_NOT_FOUND");
1140
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
+ }
1141
1446
  async function handle(request) {
1142
1447
  try {
1143
1448
  const response = await route(request);
1144
1449
  return response || json({ error: t("Route not found"), code: "ROUTE_NOT_FOUND" }, 404);
1145
1450
  } catch (error) {
1146
1451
  const status = statusOf(error);
1147
- if (status >= 500 && status !== 501) {
1452
+ if (status >= 500 && !(error instanceof HttpError)) {
1148
1453
  onError(error, request);
1149
1454
  return json({ error: t("Internal error"), code: "INTERNAL" }, 500);
1150
1455
  }
@@ -1205,49 +1510,84 @@ function toNodeListener(handler, { origin } = {}) {
1205
1510
  };
1206
1511
  }
1207
1512
  export {
1513
+ BRAND_IMPORT_LIMITS,
1514
+ BrandImportError,
1208
1515
  DEFAULT_ASSETS_PATH,
1516
+ DEFAULT_CLAUDE_MODEL,
1209
1517
  DEFAULT_VARIABLES,
1210
1518
  EMAIL_BUILDER_VERSION,
1211
1519
  EMAIL_DOCUMENT_SCHEMA_VERSION,
1520
+ EMAIL_DRAFT_SCHEMA,
1521
+ EMAIL_PURPOSES,
1212
1522
  EmailDocumentError,
1523
+ EmailGenerationError,
1524
+ GENERATION_ERRORS,
1213
1525
  GMAIL_CLIP_BYTES,
1214
1526
  HttpError,
1215
1527
  LIMITS,
1216
1528
  LOCALES,
1217
1529
  MAX_ACTIVE_VERSIONS,
1530
+ MAX_DETAILS_LENGTH,
1218
1531
  MAX_TEST_RECIPIENTS,
1532
+ PURPOSE_CATEGORIES,
1533
+ REWRITE_ACTIONS,
1534
+ REWRITE_LANGUAGES,
1535
+ REWRITE_SYSTEM_PROMPT,
1536
+ SUBJECTS_SYSTEM_PROMPT,
1537
+ SYSTEM_PROMPT,
1219
1538
  absoluteAssetUrls,
1539
+ applyBrand,
1220
1540
  applyServiceLinks,
1221
1541
  applyVariables,
1222
1542
  applyVariablesToText,
1223
1543
  assertValidMjml,
1224
1544
  assertValidProject,
1545
+ buildUserPrompt,
1546
+ claudeEmailModel,
1547
+ cleanBrand,
1548
+ cleanGenerateInput,
1225
1549
  compileMjml,
1226
1550
  compileTemplate,
1227
1551
  createAssetService,
1552
+ createBrandImporter,
1553
+ createBrandService,
1228
1554
  createDocument,
1229
1555
  createEmailBuilderHandler,
1556
+ createEmailGenerator,
1230
1557
  createMemoryAssetRepository,
1558
+ createMemoryBrandRepository,
1231
1559
  createMemoryTemplateRepository,
1232
1560
  createMemoryVariableRepository,
1561
+ createRewriter,
1562
+ createSubjectSuggester,
1233
1563
  createTemplateService,
1234
1564
  createTranslator,
1235
1565
  createVariableService,
1236
1566
  defineVariables,
1567
+ emailWords,
1237
1568
  htmlToText,
1238
1569
  inspectAsset,
1239
1570
  isAssetPath,
1571
+ isPublicAddress,
1240
1572
  keyFromLabel,
1241
1573
  mapVariables,
1242
1574
  mergeVariables,
1575
+ normalizeGeneratedMjml,
1576
+ parseDraft,
1243
1577
  personalize,
1244
1578
  prepareMjml,
1579
+ purposeById,
1580
+ purposesByCategory,
1245
1581
  qualityReport,
1246
1582
  readDocument,
1247
1583
  renderEmail,
1584
+ reviewDraft,
1585
+ reviewRewrite,
1586
+ reviewSubjects,
1248
1587
  sampleValues,
1249
1588
  sendTestEmail,
1250
1589
  serializeDocument,
1590
+ simulateDarkMode,
1251
1591
  toNodeListener,
1252
1592
  tokenOf,
1253
1593
  translatorFor,