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
@@ -0,0 +1,1776 @@
1
+ import {
2
+ DEFAULT_VARIABLES,
3
+ FONT_OPTIONS,
4
+ PALETTE,
5
+ applyBrand,
6
+ cleanBrand,
7
+ contrastRatio,
8
+ createNode,
9
+ identity,
10
+ isAssetPath,
11
+ parseColor,
12
+ parseMjml,
13
+ serializeMjml,
14
+ toHex,
15
+ toHsl
16
+ } from "./chunk-HHLOE6OZ.js";
17
+
18
+ // src/validation/urls.js
19
+ var LINK_ATTRIBUTES = /* @__PURE__ */ new Set(["href", "action", "formaction", "xlink:href"]);
20
+ var IMAGE_ATTRIBUTES = /* @__PURE__ */ new Set([
21
+ "src",
22
+ "srcset",
23
+ "background-url",
24
+ "background",
25
+ "poster",
26
+ "icon-unwrapped-url",
27
+ "icon-wrapped-url"
28
+ ]);
29
+ var NAMED_ENTITIES = {
30
+ colon: ":",
31
+ tab: " ",
32
+ newline: "\n",
33
+ lpar: "(",
34
+ rpar: ")",
35
+ amp: "&",
36
+ quot: '"',
37
+ apos: "'",
38
+ lt: "<",
39
+ gt: ">",
40
+ nbsp: " "
41
+ };
42
+ function decodeEntities(value) {
43
+ return String(value ?? "").replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);?/gi, (whole, body) => {
44
+ const key = String(body).toLowerCase();
45
+ if (key.startsWith("#x")) return String.fromCodePoint(Number.parseInt(key.slice(2), 16) || 0);
46
+ if (key.startsWith("#")) return String.fromCodePoint(Number.parseInt(key.slice(1), 10) || 0);
47
+ return NAMED_ENTITIES[key] ?? whole;
48
+ });
49
+ }
50
+ function isMergeTag(value) {
51
+ return /^\{\{\s*[a-zA-Z][\w.]*\s*\}\}$/.test(String(value || "").trim());
52
+ }
53
+ function isSafeUrl(raw, kind) {
54
+ const value = decodeEntities(raw).trim();
55
+ if (!value) return true;
56
+ if (isMergeTag(value)) return true;
57
+ const compact = value.replace(/[\s\u0000-\u001f\u007f]+/g, "").toLowerCase();
58
+ if (kind === "image") return compact.startsWith("https://") || isAssetPath(value);
59
+ if (compact.startsWith("#")) return true;
60
+ return /^(https?:\/\/|mailto:|tel:)/.test(compact);
61
+ }
62
+
63
+ // src/validation/mjmlValidator.js
64
+ var MJML_MAX_LENGTH = 512 * 1024;
65
+ var MJML_TAGS = /* @__PURE__ */ new Set([
66
+ "mjml",
67
+ "mj-head",
68
+ "mj-body",
69
+ "mj-attributes",
70
+ "mj-all",
71
+ "mj-class",
72
+ "mj-preview",
73
+ "mj-title",
74
+ "mj-breakpoint",
75
+ "mj-wrapper",
76
+ "mj-section",
77
+ "mj-group",
78
+ "mj-column",
79
+ "mj-text",
80
+ "mj-image",
81
+ "mj-button",
82
+ "mj-divider",
83
+ "mj-spacer",
84
+ "mj-table",
85
+ "mj-social",
86
+ "mj-social-element",
87
+ "mj-navbar",
88
+ "mj-navbar-link",
89
+ "mj-hero"
90
+ ]);
91
+ var INLINE_TAGS = /* @__PURE__ */ new Set([
92
+ "a",
93
+ "b",
94
+ "strong",
95
+ "i",
96
+ "em",
97
+ "u",
98
+ "s",
99
+ "strike",
100
+ "del",
101
+ "br",
102
+ "span",
103
+ "p",
104
+ "div",
105
+ "font",
106
+ "sup",
107
+ "sub",
108
+ "small",
109
+ "big",
110
+ "ul",
111
+ "ol",
112
+ "li",
113
+ "h1",
114
+ "h2",
115
+ "h3",
116
+ "h4",
117
+ "h5",
118
+ "h6",
119
+ "blockquote",
120
+ "code",
121
+ "pre",
122
+ "hr",
123
+ "img",
124
+ "table",
125
+ "tbody",
126
+ "thead",
127
+ "tr",
128
+ "td",
129
+ "th"
130
+ ]);
131
+ var FORBIDDEN_TAGS = /* @__PURE__ */ new Map([
132
+ ["mj-include", "mj-include would read a file from the server"],
133
+ ["mj-raw", "mj-raw (free HTML) is not allowed"],
134
+ ["mj-style", "mj-style (free CSS) is outside the builder"],
135
+ ["mj-font", "mj-font (external font) is outside the builder"],
136
+ ["script", "script not allowed"],
137
+ ["iframe", "iframe not allowed"],
138
+ ["frame", "frame not allowed"],
139
+ ["object", "object not allowed"],
140
+ ["embed", "embed not allowed"],
141
+ ["form", "form not allowed"],
142
+ ["input", "form field not allowed"],
143
+ ["button", "HTML button not allowed (use the Button block)"],
144
+ ["textarea", "form field not allowed"],
145
+ ["select", "form field not allowed"],
146
+ ["style", "style sheet not allowed"],
147
+ ["link", "external resource not allowed"],
148
+ ["meta", "meta tag not allowed"],
149
+ ["base", "base tag not allowed"],
150
+ ["svg", "SVG not allowed"],
151
+ ["math", "MathML not allowed"]
152
+ ]);
153
+ var TAG_RE = /<\s*(\/?)\s*([a-zA-Z][\w:-]*)([^>]*)>/g;
154
+ var ATTR_RE = /([^\s=/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
155
+ function* attributesOf(source) {
156
+ ATTR_RE.lastIndex = 0;
157
+ let match;
158
+ while (match = ATTR_RE.exec(source)) {
159
+ const value = match[2] ?? match[3] ?? match[4] ?? "";
160
+ yield { name: match[1].toLowerCase(), value };
161
+ }
162
+ }
163
+ function styleIsSafe(style) {
164
+ const css = decodeEntities(style).toLowerCase().replace(/\s+/g, "");
165
+ if (css.includes("expression(") || css.includes("javascript:") || css.includes("behavior:")) return false;
166
+ const urls = css.match(/url\(([^)]*)\)/g) || [];
167
+ return urls.every((u) => isSafeUrl(u.slice(4, -1).replace(/^['"]|['"]$/g, ""), "image"));
168
+ }
169
+ function validateMjml(mjml, { t = identity } = {}) {
170
+ const issues = [];
171
+ const source = typeof mjml === "string" ? mjml : "";
172
+ if (!source.trim()) {
173
+ return { ok: false, issues: [{ code: "EMPTY", message: t("Empty MJML") }] };
174
+ }
175
+ if (source.length > MJML_MAX_LENGTH) {
176
+ issues.push({
177
+ code: "TOO_LARGE",
178
+ message: t("MJML too large ({size} KB, maximum {max} KB)", {
179
+ size: Math.round(source.length / 1024),
180
+ max: MJML_MAX_LENGTH / 1024
181
+ })
182
+ });
183
+ }
184
+ const withoutComments = source.replace(/<!--([\s\S]*?)-->/g, (whole, body) => {
185
+ if (/\[if\s/i.test(body)) {
186
+ issues.push({ code: "CONDITIONAL_COMMENT", message: t("conditional comment not allowed") });
187
+ }
188
+ return "";
189
+ });
190
+ if (/<!\[CDATA\[/i.test(withoutComments)) {
191
+ issues.push({ code: "CDATA", message: t("CDATA section not allowed") });
192
+ }
193
+ const trimmed = withoutComments.trim();
194
+ if (!/^<mjml[\s>]/i.test(trimmed)) {
195
+ issues.push({ code: "ROOT", message: t("the document must start with <mjml>") });
196
+ }
197
+ if (!/<mj-body[\s>]/i.test(trimmed)) {
198
+ issues.push({ code: "BODY", message: t("missing <mj-body>") });
199
+ }
200
+ TAG_RE.lastIndex = 0;
201
+ let match;
202
+ while (match = TAG_RE.exec(withoutComments)) {
203
+ const closing = match[1] === "/";
204
+ const tag = match[2].toLowerCase();
205
+ if (FORBIDDEN_TAGS.has(tag)) {
206
+ const template = FORBIDDEN_TAGS.get(tag);
207
+ issues.push({ code: "FORBIDDEN_TAG", message: template ? t(template) : tag, tag });
208
+ continue;
209
+ }
210
+ if (!MJML_TAGS.has(tag) && !INLINE_TAGS.has(tag)) {
211
+ issues.push({ code: "UNKNOWN_TAG", message: t("tag not allowed: <{tag}>", { tag }), tag });
212
+ continue;
213
+ }
214
+ if (closing) continue;
215
+ for (const { name, value } of attributesOf(match[3])) {
216
+ if (name.startsWith("on")) {
217
+ issues.push({
218
+ code: "EVENT_ATTRIBUTE",
219
+ message: t("attribute {name} not allowed", { name }),
220
+ tag,
221
+ attribute: name
222
+ });
223
+ } else if (LINK_ATTRIBUTES.has(name) && !isSafeUrl(value, "link")) {
224
+ issues.push({ code: "UNSAFE_URL", message: t("URL rejected ({name})", { name }), tag, attribute: name });
225
+ } else if (IMAGE_ATTRIBUTES.has(name) && !isSafeUrl(value, "image")) {
226
+ issues.push({
227
+ code: "UNSAFE_IMAGE",
228
+ message: t("image rejected ({name}): https only", { name }),
229
+ tag,
230
+ attribute: name
231
+ });
232
+ } else if (name === "style" && !styleIsSafe(value)) {
233
+ issues.push({ code: "UNSAFE_STYLE", message: t("inline style rejected"), tag, attribute: name });
234
+ }
235
+ }
236
+ }
237
+ return { ok: issues.length === 0, issues };
238
+ }
239
+ function assertValidMjml(mjml, { t = identity } = {}) {
240
+ const { ok, issues } = validateMjml(mjml, { t });
241
+ if (ok) return;
242
+ const shown = issues.slice(0, 3).map((i) => i.message).join("; ");
243
+ const more = issues.length - 3;
244
+ const error = (
245
+ /** @type {Error & {status?: number, issues?: ValidationIssue[]}} */
246
+ new Error(
247
+ more > 0 ? t("Template rejected: {issues} (+{more})", { issues: shown, more }) : t("Template rejected: {issues}", { issues: shown })
248
+ )
249
+ );
250
+ error.status = 422;
251
+ error.issues = issues;
252
+ throw error;
253
+ }
254
+
255
+ // src/core/mjmlAttributes.js
256
+ var LONGHAND_SPACING = /* @__PURE__ */ new Set(["padding"]);
257
+ var SIDES = (
258
+ /** @type {const} */
259
+ ["top", "right", "bottom", "left"]
260
+ );
261
+ function parseSpacing(value) {
262
+ const parts = String(value || "").trim().split(/\s+/).filter(Boolean);
263
+ const [a = "0px", b = a, c = a, d = b] = parts;
264
+ return [a, b, c, d];
265
+ }
266
+ function formatSpacing(sides) {
267
+ const [t, r, b, l] = sides.map((s) => String(s || "0px"));
268
+ if (t === b && r === l) return t === r ? t : `${t} ${r}`;
269
+ if (r === l) return `${t} ${r} ${b}`;
270
+ return `${t} ${r} ${b} ${l}`;
271
+ }
272
+ function cleanAttributeValue(value) {
273
+ return String(value ?? "").replace(/["<>\r\n]/g, "").trim();
274
+ }
275
+ function toAttributes(values) {
276
+ const out = {};
277
+ for (const [key, raw] of Object.entries(values || {})) {
278
+ const value = raw === null || raw === void 0 ? "" : cleanAttributeValue(raw);
279
+ if (LONGHAND_SPACING.has(key)) {
280
+ const sides = value ? parseSpacing(value) : null;
281
+ SIDES.forEach((side, i) => {
282
+ out[`${key}-${side}`] = sides ? sides[i] : null;
283
+ });
284
+ out[key] = null;
285
+ } else {
286
+ out[key] = value || null;
287
+ }
288
+ }
289
+ return out;
290
+ }
291
+ function toValues(attributes, properties) {
292
+ const values = {};
293
+ for (const { key } of properties) {
294
+ if (LONGHAND_SPACING.has(key)) {
295
+ const base = parseSpacing(String(attributes[key] ?? ""));
296
+ const sides = SIDES.map((side, i) => {
297
+ const v = attributes[`${key}-${side}`];
298
+ return v === void 0 || v === null || v === "" ? base[i] : String(v);
299
+ });
300
+ const hasAny = attributes[key] || SIDES.some((side) => attributes[`${key}-${side}`]);
301
+ values[key] = hasAny ? formatSpacing(sides) : "";
302
+ } else {
303
+ const v = attributes[key];
304
+ values[key] = v === void 0 || v === null ? "" : String(v);
305
+ }
306
+ }
307
+ return values;
308
+ }
309
+
310
+ // src/features/generate/draft.js
311
+ var GENERATION_ERRORS = {
312
+ NOTHING_TO_GENERATE: { status: 400, message: "Pick what the email is for, or describe it." },
313
+ REFUSED: { status: 422, message: "The AI declined to write this email. Try other details." },
314
+ TRUNCATED: { status: 502, message: "The email was too long to finish. Ask for a shorter one." },
315
+ INVALID_ANSWER: { status: 502, message: "The AI answer could not be read. Try again." },
316
+ INVALID_OUTPUT: { status: 502, message: "The generated email could not be used. Try again." },
317
+ NOTHING_TO_REWRITE: { status: 400, message: "There is no text to rewrite." },
318
+ REWRITE_REFUSED: { status: 422, message: "The AI declined to rewrite this text." },
319
+ REWRITE_UNUSABLE: { status: 502, message: "The rewritten text lost a link or a variable, so nothing was changed. Try again." },
320
+ NOTHING_TO_SUGGEST: { status: 400, message: "Write the email or a subject first: the ideas are drawn from it." },
321
+ SUBJECTS_REFUSED: { status: 422, message: "The AI declined to suggest subjects for this email." },
322
+ SUBJECTS_UNUSABLE: { status: 502, message: "No usable subject came back. Try again." }
323
+ };
324
+ var EmailGenerationError = class extends Error {
325
+ /**
326
+ * @param {keyof typeof GENERATION_ERRORS} code
327
+ * @param {string} [detail] what went wrong, for logs and for the model
328
+ */
329
+ constructor(code, detail = "") {
330
+ const known = GENERATION_ERRORS[code] || GENERATION_ERRORS.INVALID_OUTPUT;
331
+ super(known.message);
332
+ this.name = "EmailGenerationError";
333
+ this.code = code;
334
+ this.status = known.status;
335
+ this.detail = detail || known.message;
336
+ }
337
+ };
338
+ var LIMITS = { name: 80, subject: 150, preheader: 200 };
339
+ function plainLine(value, max) {
340
+ return String(value ?? "").replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim().slice(0, max);
341
+ }
342
+ function parseDraft(text2) {
343
+ const source = String(text2 ?? "").trim();
344
+ const unfenced = source.replace(/^```[a-z]*\s*\n?/i, "").replace(/\n?```\s*$/, "").trim();
345
+ let data = null;
346
+ const start = unfenced.indexOf("{");
347
+ const end = unfenced.lastIndexOf("}");
348
+ if (start !== -1 && end > start) {
349
+ try {
350
+ data = JSON.parse(unfenced.slice(start, end + 1));
351
+ } catch {
352
+ data = null;
353
+ }
354
+ }
355
+ if (!data && /^<mjml[\s>]/i.test(unfenced)) data = { mjml: unfenced };
356
+ if (!data || typeof data.mjml !== "string" || !data.mjml.trim()) {
357
+ throw new EmailGenerationError("INVALID_ANSWER", 'The answer is not the expected JSON object with an "mjml" field.');
358
+ }
359
+ return {
360
+ name: plainLine(data.name, LIMITS.name),
361
+ subject: plainLine(data.subject, LIMITS.subject),
362
+ preheader: plainLine(data.preheader, LIMITS.preheader),
363
+ mjml: data.mjml.trim()
364
+ };
365
+ }
366
+ var CONTENT_TAGS = /* @__PURE__ */ new Set(["mj-text", "mj-image", "mj-button", "mj-divider", "mj-spacer", "mj-social", "mj-navbar", "mj-table"]);
367
+ var REMOVED_TAGS = /* @__PURE__ */ new Set(["mj-head", "mj-raw", "mj-style", "mj-font", "mj-include", "mj-attributes"]);
368
+ var REMOVED_ATTRIBUTES = ["css-class", "mj-class"];
369
+ var PIXEL_ATTRIBUTES = /* @__PURE__ */ new Set(["font-size", "border-radius", "icon-size", "height", "width", "letter-spacing"]);
370
+ var HERO_KEPT = ["background-color", "padding-top", "padding-right", "padding-bottom", "padding-left", "padding", "border-radius"];
371
+ var whiteSection = (children) => createNode("mj-section", { attributes: { "background-color": PALETTE.surface }, children });
372
+ var column = (children) => createNode("mj-column", { children });
373
+ function cleanAttributes(node, keepImages) {
374
+ const attributes = { ...node.attributes };
375
+ for (const name of REMOVED_ATTRIBUTES) delete attributes[name];
376
+ for (const key of LONGHAND_SPACING) {
377
+ if (attributes[key] === void 0) continue;
378
+ const sides = parseSpacing(attributes[key]);
379
+ SIDES.forEach((side, i) => {
380
+ const longhand = `${key}-${side}`;
381
+ if (attributes[longhand] === void 0) attributes[longhand] = sides[i];
382
+ });
383
+ delete attributes[key];
384
+ }
385
+ for (const [name, value] of Object.entries(attributes)) {
386
+ const unitless = /^-?\d+(\.\d+)?$/.test(value.trim());
387
+ if (unitless && (PIXEL_ATTRIBUTES.has(name) || /^padding-/.test(name))) attributes[name] = `${value.trim()}px`;
388
+ }
389
+ if (node.tag === "mj-image" && attributes.src !== void 0 && !keepImages.has(attributes.src.trim())) attributes.src = "";
390
+ delete attributes["background-url"];
391
+ return attributes;
392
+ }
393
+ function wrapLoose(children, isLoose, wrap) {
394
+ const out = [];
395
+ let run = [];
396
+ const flush = () => {
397
+ if (run.length) out.push(wrap(run));
398
+ run = [];
399
+ };
400
+ for (const child of children) {
401
+ if (isLoose(child)) run.push(child);
402
+ else {
403
+ flush();
404
+ out.push(child);
405
+ }
406
+ }
407
+ flush();
408
+ return out;
409
+ }
410
+ function cleanNode(node, keepImages) {
411
+ if (REMOVED_TAGS.has(node.tag)) return [];
412
+ const children = node.children.flatMap((child) => cleanNode(child, keepImages));
413
+ const attributes = cleanAttributes(node, keepImages);
414
+ if (node.tag === "mj-wrapper") {
415
+ return children.map(
416
+ (child) => child.attributes["background-color"] || !attributes["background-color"] ? child : { ...child, attributes: { ...child.attributes, "background-color": attributes["background-color"] } }
417
+ );
418
+ }
419
+ if (node.tag === "mj-group") return children;
420
+ if (node.tag === "mj-hero") {
421
+ const kept = Object.fromEntries(Object.entries(attributes).filter(([name]) => HERO_KEPT.includes(name)));
422
+ const columns = children.some((child) => child.tag === "mj-column") ? children : [column(children)];
423
+ return [createNode("mj-section", { id: node.id, attributes: { "background-color": PALETTE.navy, ...kept }, children: columns })];
424
+ }
425
+ if (node.tag === "mj-section") {
426
+ return [{ ...node, attributes, children: wrapLoose(children, (child) => child.tag !== "mj-column", column) }];
427
+ }
428
+ if (node.tag === "mj-body") {
429
+ const sectioned = wrapLoose(children, (child) => child.tag === "mj-column", (nodes) => whiteSection(nodes));
430
+ const wrapped = wrapLoose(sectioned, (child) => CONTENT_TAGS.has(child.tag), (nodes) => whiteSection([column(nodes)]));
431
+ return [{ ...node, attributes: { "background-color": PALETTE.canvas, ...attributes, width: "600px" }, children: wrapped }];
432
+ }
433
+ return [{ ...node, attributes, children }];
434
+ }
435
+ var placeholderOf = (key) => `[${key.replace(/[._]+/g, " ").trim()}]`;
436
+ function replaceUnknownVariables(mjml, known) {
437
+ const unknown = (key) => !known.has(key);
438
+ const inUrls = mjml.replace(
439
+ /\s(href|src)="([^"]*)"/g,
440
+ (whole, name, value) => /\{\{\s*([A-Za-z][\w.]*)/.test(value) && [...value.matchAll(/\{\{\s*([A-Za-z][\w.]*)/g)].some((m) => unknown(m[1])) ? ` ${name}="${name === "href" ? "https://example.com" : ""}"` : whole
441
+ );
442
+ return inUrls.replace(/\{\{\s*([A-Za-z][\w.]*)\s*(?:\|[^{}]*)?\}\}/g, (whole, key) => unknown(key) ? placeholderOf(key) : whole);
443
+ }
444
+ function normalizeGeneratedMjml(mjml, { variables = [], keepImages = [] } = {}) {
445
+ const tree = parseMjml(String(mjml || ""));
446
+ const [root] = cleanNode(tree, new Set(keepImages.filter(Boolean)));
447
+ const known = /* @__PURE__ */ new Set(["unsubscribe", ...variables.map((variable) => variable.key)]);
448
+ return replaceUnknownVariables(serializeMjml(root), known);
449
+ }
450
+ function reviewDraft(draft, { kind = "marketing", compileWarnings = [] } = {}) {
451
+ const blocking = validateMjml(draft.mjml).issues.map((issue) => issue.message);
452
+ const advice = [];
453
+ if (!/<mj-section[\s>]/.test(draft.mjml)) advice.push("The email has no <mj-section>: build it from sections and columns.");
454
+ const unsubscribe = /\{\{\s*unsubscribe\s*\}\}/.test(draft.mjml);
455
+ if (kind !== "security" && !unsubscribe) {
456
+ advice.push('The footer has no link with href="{{ unsubscribe }}".');
457
+ }
458
+ if (kind === "security" && unsubscribe) advice.push("A security email must not have an unsubscribe link.");
459
+ if (!draft.subject) advice.push("The subject is empty.");
460
+ if (!draft.preheader) advice.push("The preheader is empty.");
461
+ for (const warning of compileWarnings.slice(0, 8)) advice.push(`MJML: ${warning.message}`);
462
+ return { blocking, advice };
463
+ }
464
+
465
+ // src/features/generate/prompt.js
466
+ var EMAIL_DRAFT_SCHEMA = {
467
+ type: "object",
468
+ properties: {
469
+ name: { type: "string", description: "Short internal name of the template" },
470
+ subject: { type: "string", description: "Subject line" },
471
+ preheader: { type: "string", description: "Preview text shown after the subject in the inbox" },
472
+ mjml: { type: "string", description: "The complete MJML document" }
473
+ },
474
+ required: ["name", "subject", "preheader", "mjml"],
475
+ additionalProperties: false
476
+ };
477
+ var FONTS = FONT_OPTIONS.map((option) => `"${option.value}"`).join(", ");
478
+ var SYSTEM_PROMPT = `You are a senior email designer and copywriter. You write complete, ready-to-send marketing and transactional emails in MJML for a visual email builder. The user will open your email in the builder, edit it block by block and send it, so it must follow the builder's conventions exactly.
479
+
480
+ # Answer
481
+ Return one JSON object, nothing else: {"name": string, "subject": string, "preheader": string, "mjml": string}.
482
+ - name: a short internal template name (at most 50 characters), in the email's language.
483
+ - subject: at most 60 characters, specific and honest, no ALL CAPS words, no spam triggers ("FREE!!!", "$$$"), at most one emoji and only when the occasion is festive.
484
+ - preheader: 40 to 110 characters that complete the subject (never repeat it).
485
+ - mjml: the full MJML document.
486
+
487
+ # MJML the builder can edit
488
+ - Skeleton: <mjml><mj-body background-color="${PALETTE.canvas}" width="600px"> ...sections... </mj-body></mjml>. No <mj-head>: the builder adds the title, preview text and language itself.
489
+ - The body is a vertical stack of <mj-section>. Each section holds one to four <mj-column>. Content elements live only inside a column.
490
+ - Allowed elements, and only these: mj-section, mj-column, mj-text, mj-image, mj-button, mj-divider, mj-spacer, mj-social, mj-social-element.
491
+ - Never use mj-hero, mj-wrapper, mj-group, mj-table, mj-navbar, mj-raw, mj-style, mj-font, mj-include, mj-attributes, mj-class, the css-class attribute, <style> or <script>.
492
+ - Inside mj-text and mj-button, only simple inline HTML: <b>, <strong>, <i>, <em>, <u>, <br />, <a href="..." style="...">, <span style="...">, <p style="margin:0 0 12px 0;">, <ul>, <ol>, <li>. No <h1>-<h6>, <div>, <table> or <img> in text: a heading is an mj-text written large and bold.
493
+ - Inline style attributes only on <a>, <span>, <p>, <li>, and only for color, font-weight, font-style, text-decoration and margin.
494
+
495
+ # Attributes
496
+ - Padding ALWAYS as the four longhand attributes padding-top, padding-right, padding-bottom, padding-left. Never the padding="..." shorthand. The one exception: inner-padding on mj-button (shorthand, for example inner-padding="12px 28px").
497
+ - Every mj-text and mj-button sets font-family, font-size, line-height and color itself (plus font-weight="bold" when bold). Sizes in px: font-size="16px", line-height="24px".
498
+ - Fonts: web-safe only, one family for the whole email (at most two: one for headings, one for text): ${FONTS}. No web fonts.
499
+ - Side spacing: content elements of one-column sections take padding-left="32px" padding-right="32px". In two- to four-column sections use 12px to 16px on the sides of each element.
500
+ - Sections: background-color="${PALETTE.surface}" for content (or a brand tint), padding-top and padding-bottom between 8px and 48px. The email reads as one card on the ${PALETTE.canvas} body background.
501
+ - Column widths in percent only when unequal (for example width="40%" and width="60%"), always adding up to 100%.
502
+ - Images: <mj-image src="" alt="..." ...>. Leave src EMPTY: the user uploads their own pictures. Always write an alt that describes the picture to place, in the email's language. No background-url either. Use 0 to 3 images; product images in columns.
503
+ - Buttons: border-radius="6px", font-weight="bold", inner-padding="12px 28px", aligned like the text around them, href with an https URL. When the real URL is unknown, use https://example.com followed by a meaningful path (https://example.com/reset-password).
504
+ - Dividers: border-color in a light gray, border-width="1px".
505
+ - Social icons (only when the details ask for them, in the footer): <mj-social mode="horizontal" align="center" icon-size="28px" border-radius="14px" inner-padding="6px"> with <mj-social-element name="facebook-noshare" href="https://..."></mj-social-element>; names: facebook-noshare, instagram-noshare, linkedin-noshare, x-noshare, youtube-noshare, github-noshare.
506
+
507
+ # Design
508
+ - Structure: a header section (the sender's logo or name), a hero section, one to four body sections, a footer section. Three to eight sections in total.
509
+ - One brand color for buttons and accents (use the one given, otherwise ${PALETTE.brand} or a color that suits the occasion), text ${PALETTE.ink} on white, headings ${PALETTE.navy} or the brand color.
510
+ - Contrast: body text at least 4.5:1 against its background, large text and buttons at least 3:1. White text only on dark backgrounds.
511
+ - Type scale: main heading 28 to 32px with line-height 36 to 40px, section headings 20 to 22px, body 15 to 16px with line-height 24 to 26px, small print 12 to 13px.
512
+ - A hero is a section with a background color, a large heading, one sentence and usually the main button, centered.
513
+ - One primary call to action (two at most), with a clear 2- to 4-word label that starts with a verb. Repeat it at most once at the end of a long email.
514
+ - Mobile first: columns stack on phones, so each column must make sense on its own. Short paragraphs of one to three sentences.
515
+
516
+ # Copy
517
+ - Write the real email for the purpose and the details given: specific, useful, warm and concise. Never lorem ipsum, never meta comments.
518
+ - Personalize with the variables listed in the request, written exactly as {{key}}. A greeting gives the first name a natural default after a pipe, in the email's language, used when the name is unknown: Hello {{contact.firstname|there}},. Use no other {{...}} variable.
519
+ - Information that is not given and not in the variables (an order number, a date, a price, a code, an address) is written as a short placeholder in square brackets that the user will replace: [order number], [date], [20%]. Never invent facts that would be wrong.
520
+ - Links read as what they do ("Reset my password", "See the collection"), never "click here".
521
+ - The footer (last section): the sender's name and postal address (or [Company address]) in 12px #6d7484 text, centered, and the footer line the request asks for. The unsubscribe link is exactly <a href="{{ unsubscribe }}" style="color:#6d7484;text-decoration:underline;">...</a>.
522
+ - Keep the MJML under 30 KB.`;
523
+ var FOOTER_RULES = {
524
+ marketing: 'This is a marketing email: the footer MUST contain the unsubscribe link (href="{{ unsubscribe }}").',
525
+ transactional: 'This is a transactional email: the footer contains a discreet "Manage email preferences" link with href="{{ unsubscribe }}".',
526
+ security: "This is a security email: NO unsubscribe or preferences link. The footer says in one line why the recipient got this email."
527
+ };
528
+ function languageName(code) {
529
+ const value = String(code || "en");
530
+ try {
531
+ return new Intl.DisplayNames(["en"], { type: "language" }).of(value) || value;
532
+ } catch {
533
+ return value;
534
+ }
535
+ }
536
+ function variableLines(variables) {
537
+ return variables.filter((variable) => variable.key !== "unsubscribe").map((variable) => `- {{${variable.key}}}${variable.label ? `: ${variable.label}` : ""}`).join("\n");
538
+ }
539
+ function buildUserPrompt({ purpose: purpose2 = null, details = "", language = "en", sender = {}, brand = {}, variables = [] }) {
540
+ const lines = [];
541
+ if (purpose2) {
542
+ lines.push(`Write a "${purpose2.label}" email.`, "", `Brief: ${purpose2.brief}`);
543
+ } else {
544
+ lines.push("Write the email described below.");
545
+ }
546
+ const extra = String(details || "").trim();
547
+ if (extra) lines.push("", "Details from the user (they take precedence over the brief):", extra);
548
+ lines.push(
549
+ "",
550
+ `Language: write everything in ${languageName(language)}, unless the details ask for another language.`,
551
+ "",
552
+ "Sender:",
553
+ `- name: ${sender.name?.trim() || "[Company name]"}`,
554
+ `- postal address: ${sender.address?.trim() || "[Company address]"}`,
555
+ sender.logoUrl ? `- logo: put <mj-image src="${sender.logoUrl}" alt="${sender.name || "Logo"}" width="140px" align="left"> in the header (this is the one image whose src you fill).` : "- logo: none, write the sender name in bold in the header."
556
+ );
557
+ const look = [
558
+ brand.primary && `- main color (buttons, banners, links): ${brand.primary}`,
559
+ brand.heading && `- headings color: ${brand.heading}`,
560
+ brand.font && `- font: font-family="${brand.font}" on every text and button`
561
+ ].filter(Boolean);
562
+ if (look.length) lines.push("", "Brand (use exactly these):", ...look);
563
+ const vars = variableLines(variables);
564
+ if (vars) lines.push("", "Variables you may use:", vars);
565
+ lines.push("", FOOTER_RULES[purpose2?.kind || "marketing"]);
566
+ return lines.join("\n");
567
+ }
568
+ function buildRepairPrompt(problems) {
569
+ return [
570
+ "The email cannot be opened in the builder as it is. Fix these problems and return the complete corrected JSON object:",
571
+ ...problems.map((problem) => `- ${problem}`)
572
+ ].join("\n");
573
+ }
574
+
575
+ // src/features/generate/purposes.js
576
+ var PURPOSE_CATEGORIES = [
577
+ { id: "promotions", label: "Sales and promotions" },
578
+ { id: "holidays", label: "Holidays and occasions" },
579
+ { id: "events", label: "Events and appointments" },
580
+ { id: "news", label: "News and content" },
581
+ { id: "account", label: "Account and security" },
582
+ { id: "orders", label: "Orders and payments" },
583
+ { id: "relationship", label: "Customer relationship" },
584
+ { id: "organization", label: "Business and organization" }
585
+ ];
586
+ var purpose = (id, label, category, kind, brief) => ({ id, label, category, kind, brief });
587
+ var EMAIL_PURPOSES = [
588
+ // Sales and promotions
589
+ purpose(
590
+ "sale",
591
+ "Store sale",
592
+ "promotions",
593
+ "marketing",
594
+ 'An online store sale. A bold hero with the offer (discount and end date), a short reason to buy now, 2 to 4 featured products in two columns (image, name, old and new price, link), one "Shop the sale" button, and the conditions in small text.'
595
+ ),
596
+ purpose(
597
+ "flash-sale",
598
+ "Flash sale",
599
+ "promotions",
600
+ "marketing",
601
+ "A sale that lasts hours, not days. Urgency first: the deadline in the hero, a large discount, one button, very little text."
602
+ ),
603
+ purpose(
604
+ "discount-code",
605
+ "Discount code",
606
+ "promotions",
607
+ "marketing",
608
+ "A personal discount code. The code in a large, easy-to-copy box (a colored section with the code in big letters), what it gives, its expiry date, and a button to use it."
609
+ ),
610
+ purpose(
611
+ "new-product",
612
+ "New product",
613
+ "promotions",
614
+ "marketing",
615
+ 'A product launch. A hero image with the product name, three benefits in three short columns or a list, the price or availability, and a "Discover" button.'
616
+ ),
617
+ purpose(
618
+ "back-in-stock",
619
+ "Back in stock",
620
+ "promotions",
621
+ "marketing",
622
+ 'A product the recipient wanted is available again. The product (image, name, price), "limited stock" urgency without exaggeration, and a "Buy now" button.'
623
+ ),
624
+ purpose(
625
+ "price-drop",
626
+ "Price drop",
627
+ "promotions",
628
+ "marketing",
629
+ "A product the recipient looked at is now cheaper. Old and new price side by side, the product image, and a button."
630
+ ),
631
+ purpose(
632
+ "abandoned-cart",
633
+ "Abandoned cart",
634
+ "promotions",
635
+ "marketing",
636
+ 'Items left in the cart. A friendly reminder, the cart items (image, name, price), a "Return to my cart" button, and a reassurance line (free returns, secure payment, help contact).'
637
+ ),
638
+ purpose(
639
+ "black-friday",
640
+ "Black Friday",
641
+ "promotions",
642
+ "marketing",
643
+ "A Black Friday or Cyber Monday campaign. A dark, high-contrast hero with the offer and dates, featured categories or products in columns, and a strong button."
644
+ ),
645
+ purpose(
646
+ "loyalty-reward",
647
+ "Loyalty reward",
648
+ "promotions",
649
+ "marketing",
650
+ "Thank a loyal customer with a reward: their status or points balance, the reward, how to use it, and a button."
651
+ ),
652
+ purpose(
653
+ "refer-a-friend",
654
+ "Refer a friend",
655
+ "promotions",
656
+ "marketing",
657
+ "Invite the customer to refer friends. What the friend gets, what the customer gets, three short steps, and a button to share their link."
658
+ ),
659
+ // Holidays and occasions
660
+ purpose(
661
+ "christmas",
662
+ "Christmas",
663
+ "holidays",
664
+ "marketing",
665
+ "Christmas greetings from the company, warm and festive (deep red, green or gold accents), a short heartfelt message, an optional gift idea or offer, and the holiday opening hours if relevant."
666
+ ),
667
+ purpose(
668
+ "new-year",
669
+ "New Year",
670
+ "holidays",
671
+ "marketing",
672
+ "New Year wishes. A celebratory hero, thanks for the past year, one or two highlights or promises for the year ahead, and warm wishes."
673
+ ),
674
+ purpose(
675
+ "season-greetings",
676
+ "Season's greetings",
677
+ "holidays",
678
+ "marketing",
679
+ "End-of-year greetings that suit every culture and belief (no religious reference): thanks, warm wishes, and the holiday closing dates."
680
+ ),
681
+ purpose(
682
+ "valentines-day",
683
+ "Valentine's Day",
684
+ "holidays",
685
+ "marketing",
686
+ "Valentine's Day: a romantic tone (soft pink or red), gift ideas in columns, and a delivery deadline."
687
+ ),
688
+ purpose(
689
+ "easter",
690
+ "Easter",
691
+ "holidays",
692
+ "marketing",
693
+ "Easter: a spring palette, greetings, and an optional seasonal offer."
694
+ ),
695
+ purpose(
696
+ "mothers-day",
697
+ "Mother's Day",
698
+ "holidays",
699
+ "marketing",
700
+ "Mother's Day: gift ideas in columns, the order deadline for delivery in time, and a gentle tone."
701
+ ),
702
+ purpose(
703
+ "fathers-day",
704
+ "Father's Day",
705
+ "holidays",
706
+ "marketing",
707
+ "Father's Day: gift ideas in columns, the order deadline for delivery in time, and a warm tone."
708
+ ),
709
+ purpose(
710
+ "halloween",
711
+ "Halloween",
712
+ "holidays",
713
+ "marketing",
714
+ "Halloween: a playful, spooky tone (orange and near-black, still readable), a themed offer or event."
715
+ ),
716
+ purpose(
717
+ "back-to-school",
718
+ "Back to school",
719
+ "holidays",
720
+ "marketing",
721
+ "Back to school: essentials or a checklist in columns, an offer, and a cheerful tone."
722
+ ),
723
+ purpose(
724
+ "summer",
725
+ "Summer holidays",
726
+ "holidays",
727
+ "marketing",
728
+ "Summer: a bright palette, summer picks or plans, and the summer opening hours if relevant."
729
+ ),
730
+ purpose(
731
+ "birthday",
732
+ "Birthday wishes",
733
+ "holidays",
734
+ "marketing",
735
+ "Happy birthday to the recipient by first name: a festive hero, a personal message, and a birthday gift or code with its validity."
736
+ ),
737
+ purpose(
738
+ "anniversary",
739
+ "Customer anniversary",
740
+ "holidays",
741
+ "marketing",
742
+ "One (or more) years since the recipient became a customer: thanks, a short look back, and a small gift."
743
+ ),
744
+ purpose(
745
+ "womens-day",
746
+ "Women's Day",
747
+ "holidays",
748
+ "marketing",
749
+ "International Women's Day (March 8): respectful greetings, an optional offer or a story about women of the team, no clich\xE9s."
750
+ ),
751
+ purpose(
752
+ "thanksgiving",
753
+ "Thanksgiving",
754
+ "holidays",
755
+ "marketing",
756
+ "Thanksgiving: gratitude to customers, a warm autumn palette, and an optional offer."
757
+ ),
758
+ purpose(
759
+ "lunar-new-year",
760
+ "Lunar New Year",
761
+ "holidays",
762
+ "marketing",
763
+ "Lunar New Year: wishes for the new year (red and gold accents), the animal of the year if relevant, and an optional offer."
764
+ ),
765
+ purpose(
766
+ "ramadan-eid",
767
+ "Ramadan and Eid",
768
+ "holidays",
769
+ "marketing",
770
+ "Ramadan Kareem or Eid Mubarak greetings: respectful and warm, adapted opening hours, an optional offer."
771
+ ),
772
+ purpose(
773
+ "diwali",
774
+ "Diwali",
775
+ "holidays",
776
+ "marketing",
777
+ "Diwali, the festival of lights: warm wishes, a light-filled palette (gold, deep orange), an optional offer."
778
+ ),
779
+ // Events and appointments
780
+ purpose(
781
+ "meeting-reminder",
782
+ "Meeting reminder",
783
+ "events",
784
+ "transactional",
785
+ "A reminder of an upcoming meeting: what, when (date, time, time zone), where (address or video link), who, what to prepare, and buttons to join and to reschedule."
786
+ ),
787
+ purpose(
788
+ "appointment-confirmation",
789
+ "Appointment confirmation",
790
+ "events",
791
+ "transactional",
792
+ "Confirm a booked appointment (doctor, salon, garage, consultant): service, date and time, address with directions, how to cancel or reschedule, and a contact."
793
+ ),
794
+ purpose(
795
+ "reservation-confirmation",
796
+ "Booking confirmation",
797
+ "events",
798
+ "transactional",
799
+ "Confirm a booking (restaurant table, hotel room, class): booking details in a clear summary box, arrival information, and how to change it."
800
+ ),
801
+ purpose(
802
+ "event-invitation",
803
+ "Event invitation",
804
+ "events",
805
+ "marketing",
806
+ 'Invite to an event: a hero with the name, date and place, what to expect (program or speakers in columns), and an "I will come" RSVP button.'
807
+ ),
808
+ purpose(
809
+ "webinar-invitation",
810
+ "Webinar invitation",
811
+ "events",
812
+ "marketing",
813
+ 'Invite to a webinar: the topic and what attendees will learn (three points), the speaker, date and time with time zone, and a "Register" button.'
814
+ ),
815
+ purpose(
816
+ "save-the-date",
817
+ "Save the date",
818
+ "events",
819
+ "marketing",
820
+ 'Announce a date to keep free before the full invitation: the event name and date very large, one sentence, and an "Add to calendar" button.'
821
+ ),
822
+ purpose(
823
+ "registration-confirmed",
824
+ "Registration confirmed",
825
+ "events",
826
+ "transactional",
827
+ 'Confirm a registration to an event, a course or a webinar: what, when, where or the access link, an "Add to calendar" button, and how to cancel.'
828
+ ),
829
+ purpose(
830
+ "event-change",
831
+ "Cancellation or new date",
832
+ "events",
833
+ "transactional",
834
+ "An event or appointment is cancelled or moved: the change in the first line, the new date if any, what happens to tickets or bookings, an apology, and a contact."
835
+ ),
836
+ purpose(
837
+ "event-follow-up",
838
+ "Thanks for attending",
839
+ "events",
840
+ "marketing",
841
+ "Thank attendees after an event: a recap, links to the replay or slides, a short survey, and the next event."
842
+ ),
843
+ // News and content
844
+ purpose(
845
+ "newsletter",
846
+ "Newsletter",
847
+ "news",
848
+ "marketing",
849
+ 'A newsletter: an intro, a lead story with an image, 2 or 3 shorter articles (title, two lines, "Read more" link), and an optional tip or quote.'
850
+ ),
851
+ purpose(
852
+ "new-article",
853
+ "New article",
854
+ "news",
855
+ "marketing",
856
+ 'Share one new blog article or video: its image, title, a two-sentence teaser, and a "Read the article" button.'
857
+ ),
858
+ purpose(
859
+ "product-update",
860
+ "What's new",
861
+ "news",
862
+ "marketing",
863
+ "A product update or release notes: the headline feature with an image, 2 or 3 smaller improvements, and a button to try it."
864
+ ),
865
+ purpose(
866
+ "announcement",
867
+ "Announcement",
868
+ "news",
869
+ "marketing",
870
+ "An important announcement (new name, new address, partnership, milestone): what changes, why, what it means for the recipient, and a button."
871
+ ),
872
+ purpose(
873
+ "tips",
874
+ "Tips and how-to",
875
+ "news",
876
+ "marketing",
877
+ "Practical tips: 3 to 5 numbered tips, each with a short title and one or two sentences, and a button to learn more."
878
+ ),
879
+ // Account and security
880
+ purpose(
881
+ "welcome",
882
+ "Welcome",
883
+ "account",
884
+ "transactional",
885
+ "Welcome a new user or subscriber: a warm hero, what they can expect, 3 first steps, and one button to get started."
886
+ ),
887
+ purpose(
888
+ "verify-email",
889
+ "Email verification",
890
+ "account",
891
+ "security",
892
+ 'Ask to confirm the email address: one short sentence, a "Confirm my email" button, the link validity, and "If you did not sign up, ignore this email".'
893
+ ),
894
+ purpose(
895
+ "password-reset",
896
+ "Password reset",
897
+ "account",
898
+ "security",
899
+ 'A password reset: "We received a request to reset your password", a "Reset my password" button, the link expiry, and "If you did not ask, ignore this email; your password stays the same". Sober and reassuring, no marketing.'
900
+ ),
901
+ purpose(
902
+ "login-code",
903
+ "Login code",
904
+ "account",
905
+ "security",
906
+ "A one-time sign-in code: the code as a placeholder in very large letters in a light box, its validity in minutes, and a warning never to share it. No button."
907
+ ),
908
+ purpose(
909
+ "security-alert",
910
+ "Security alert",
911
+ "account",
912
+ "security",
913
+ 'A new sign-in or account change: what happened, when, device and location, "If it was you, nothing to do", and a "Secure my account" button.'
914
+ ),
915
+ purpose(
916
+ "onboarding",
917
+ "Getting started",
918
+ "account",
919
+ "transactional",
920
+ "Help a new user get value: one key feature explained in 3 steps with short texts, a button, and where to find help."
921
+ ),
922
+ purpose(
923
+ "trial-ending",
924
+ "Trial ending",
925
+ "account",
926
+ "transactional",
927
+ 'The free trial ends soon: the end date, what they lose, the plans or price, and an "Upgrade" button.'
928
+ ),
929
+ // Orders and payments
930
+ purpose(
931
+ "order-confirmation",
932
+ "Order confirmation",
933
+ "orders",
934
+ "transactional",
935
+ 'Confirm an order: thanks, order number and date, the items in a summary (name, quantity, price), totals, shipping address, delivery estimate, and a "View my order" button.'
936
+ ),
937
+ purpose(
938
+ "order-shipped",
939
+ "Order shipped",
940
+ "orders",
941
+ "transactional",
942
+ 'The order is on its way: carrier, expected delivery date, a "Track my parcel" button, and the shipped items.'
943
+ ),
944
+ purpose(
945
+ "delivery-delay",
946
+ "Delivery delay",
947
+ "orders",
948
+ "transactional",
949
+ 'An order will arrive late: an apology, the new estimated date, the reason in one sentence, an optional gesture, and a "Track my order" button.'
950
+ ),
951
+ purpose(
952
+ "receipt",
953
+ "Receipt or invoice",
954
+ "orders",
955
+ "transactional",
956
+ 'A payment receipt or invoice: amount paid, date, payment method, a clear breakdown, and a "Download the invoice" button.'
957
+ ),
958
+ purpose(
959
+ "payment-failed",
960
+ "Payment failed",
961
+ "orders",
962
+ "transactional",
963
+ 'A payment did not go through: calm and helpful, what happens next and when, and an "Update my payment method" button.'
964
+ ),
965
+ purpose(
966
+ "subscription-renewal",
967
+ "Subscription renewal",
968
+ "orders",
969
+ "transactional",
970
+ "An upcoming subscription renewal: plan, amount, renewal date, and buttons to manage the subscription."
971
+ ),
972
+ purpose(
973
+ "refund",
974
+ "Refund issued",
975
+ "orders",
976
+ "transactional",
977
+ "A refund was issued: amount, original order, when it reaches the account, and a contact for questions."
978
+ ),
979
+ // Customer relationship
980
+ purpose(
981
+ "thank-you",
982
+ "Thank you",
983
+ "relationship",
984
+ "marketing",
985
+ "A sincere thank-you (for a purchase, a year together, a donation, a recommendation): personal tone, short, and an optional small gift."
986
+ ),
987
+ purpose(
988
+ "review-request",
989
+ "Review request",
990
+ "relationship",
991
+ "marketing",
992
+ 'Ask for a review of a recent purchase or visit: the product or service, one sentence on why reviews matter, and a "Leave a review" button.'
993
+ ),
994
+ purpose(
995
+ "survey",
996
+ "Survey",
997
+ "relationship",
998
+ "marketing",
999
+ 'Ask for feedback: how long it takes (for example 2 minutes), why it helps, an optional incentive, and a "Take the survey" button.'
1000
+ ),
1001
+ purpose(
1002
+ "win-back",
1003
+ "We miss you",
1004
+ "relationship",
1005
+ "marketing",
1006
+ "Re-engage an inactive customer: what is new since their last visit, a comeback offer, and a button."
1007
+ ),
1008
+ purpose(
1009
+ "follow-up",
1010
+ "Follow-up",
1011
+ "relationship",
1012
+ "transactional",
1013
+ "A personal follow-up after a meeting, a call or a quote: a reminder of what was discussed, the next step, and an easy way to reply or book a call. Short and personal, like a message from one person."
1014
+ ),
1015
+ purpose(
1016
+ "apology",
1017
+ "Apology",
1018
+ "relationship",
1019
+ "transactional",
1020
+ "A sincere apology for a problem (outage, mistake, late delivery): what happened, what was done to fix it, a gesture if any, and a contact. No marketing."
1021
+ ),
1022
+ // Business and organization
1023
+ purpose(
1024
+ "hiring",
1025
+ "We're hiring",
1026
+ "organization",
1027
+ "marketing",
1028
+ 'Job openings: why join the team, 2 to 4 open roles (title, place, one line), and a "See the jobs" button.'
1029
+ ),
1030
+ purpose(
1031
+ "policy-update",
1032
+ "Policy update",
1033
+ "organization",
1034
+ "transactional",
1035
+ "Updated terms or privacy policy: the date it takes effect, the key changes in a short list, and a link to the full text. Neutral and clear."
1036
+ ),
1037
+ purpose(
1038
+ "price-change",
1039
+ "Price change",
1040
+ "organization",
1041
+ "transactional",
1042
+ "A price change notice: old and new price, the effective date, why, and how to change plan or cancel. Honest and respectful."
1043
+ ),
1044
+ purpose(
1045
+ "maintenance",
1046
+ "Scheduled maintenance",
1047
+ "organization",
1048
+ "transactional",
1049
+ "Planned maintenance or an incident: when, how long, what is affected, and where to follow the status."
1050
+ ),
1051
+ purpose(
1052
+ "closure",
1053
+ "Closure or opening hours",
1054
+ "organization",
1055
+ "marketing",
1056
+ "Holiday closure or new opening hours: the dates and hours very clearly, what customers can still do, and wishes."
1057
+ ),
1058
+ purpose(
1059
+ "donation-appeal",
1060
+ "Donation appeal",
1061
+ "organization",
1062
+ "marketing",
1063
+ 'A fundraising appeal for a nonprofit: one concrete story, what a gift makes possible (amount examples), and a "Donate" button.'
1064
+ ),
1065
+ purpose(
1066
+ "team-update",
1067
+ "Internal update",
1068
+ "organization",
1069
+ "transactional",
1070
+ "An internal message to employees: news, dates to remember, and a contact. Plain and friendly."
1071
+ )
1072
+ ];
1073
+ function purposeById(id) {
1074
+ return EMAIL_PURPOSES.find((entry) => entry.id === id) || null;
1075
+ }
1076
+ function purposesByCategory(purposes = EMAIL_PURPOSES) {
1077
+ return PURPOSE_CATEGORIES.map((category) => ({
1078
+ ...category,
1079
+ purposes: purposes.filter((entry) => entry.category === category.id)
1080
+ })).filter((group) => group.purposes.length > 0);
1081
+ }
1082
+
1083
+ // src/features/generate/rewrite.js
1084
+ var REWRITE_ACTIONS = [
1085
+ { id: "shorter", label: "Shorter", change: "Make it noticeably shorter, about half as long. Keep the meaning, the key facts and every link." },
1086
+ {
1087
+ id: "longer",
1088
+ label: "Longer",
1089
+ change: "Make it somewhat longer, about one and a half times, and more vivid. Develop what is there; invent no fact."
1090
+ },
1091
+ {
1092
+ id: "fix",
1093
+ label: "Fix mistakes",
1094
+ change: "Fix spelling, grammar and punctuation only. Change nothing else: same words, same tone, same length."
1095
+ },
1096
+ { id: "friendlier", label: "Friendlier", change: "Make the tone warmer and friendlier, as if talking to a customer you like. Same facts, about the same length." },
1097
+ { id: "formal", label: "More formal", change: "Make the tone more professional and formal. Same facts, about the same length." }
1098
+ ];
1099
+ var REWRITE_LANGUAGES = ["en", "fr", "de", "es", "it", "pt", "nl", "pl", "uk", "ru", "tr", "ja", "zh"];
1100
+ var MAX_REWRITE_TEXT = 6e3;
1101
+ var MAX_REWRITE_INSTRUCTION = 300;
1102
+ var REWRITE_SCHEMA = {
1103
+ type: "object",
1104
+ properties: { text: { type: "string", description: "The rewritten text, nothing else." } },
1105
+ required: ["text"],
1106
+ additionalProperties: false
1107
+ };
1108
+ var REWRITE_SYSTEM_PROMPT = `You are an email copywriter. You rewrite ONE piece of text taken from an email, as asked, and answer with the rewritten text only, in the JSON field "text".
1109
+
1110
+ The text is one of:
1111
+ - HTML, the content of a text block: keep the same kind of markup. Allowed tags: <p>, <br>, <b>, <strong>, <i>, <em>, <u>, <s>, <span style="...">, <a href="...">, <ul>, <ol>, <li>, <sup>, <sub>. No other tag, no class, no new style. Keep bold, italic and links on the words they belong to.
1112
+ - plain text, the label of a button: answer plain text without HTML, a short call to action of a few words.
1113
+
1114
+ Always:
1115
+ - Keep every link: each <a href="..."> of the text stays, with its href unchanged; the words of the link may change. Add no link.
1116
+ - Keep every merge tag in double braces exactly as written, e.g. {{contact.firstname}} or {{ unsubscribe }}. In {{key|fallback}} only the fallback after "|" may change (translated, for instance). Add no merge tag.
1117
+ - Keep [placeholders in square brackets]: the user replaces them later. Translate their words when translating.
1118
+ - Invent no facts: no new price, date, number, name, guarantee or promise.
1119
+ - Write in the language of the text, unless asked to translate.
1120
+ - No preamble, no comment, no quotation marks around the text.`;
1121
+ var bounded = (value, max) => typeof value === "string" ? value.trim().slice(0, max) : "";
1122
+ var wordsOf = (html) => html.replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
1123
+ function cleanRewriteInput(input) {
1124
+ const source = input && typeof input === "object" ? input : (
1125
+ /** @type {RewriteInput} */
1126
+ { action: "", text: "" }
1127
+ );
1128
+ const format = (
1129
+ /** @type {'html'|'plain'} */
1130
+ source.format === "plain" ? "plain" : "html"
1131
+ );
1132
+ const text2 = bounded(source.text, MAX_REWRITE_TEXT);
1133
+ if (!wordsOf(text2)) throw new EmailGenerationError("NOTHING_TO_REWRITE");
1134
+ const preset = REWRITE_ACTIONS.find((action) => action.id === source.action);
1135
+ if (preset) return { format, text: text2, change: preset.change };
1136
+ if (source.action === "translate") {
1137
+ const language = String(source.targetLanguage || "");
1138
+ if (!/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(language)) throw new EmailGenerationError("NOTHING_TO_REWRITE", "No target language.");
1139
+ return { format, text: text2, change: `Translate it into ${languageName(language)}. Keep the tone and the length.` };
1140
+ }
1141
+ const instruction = bounded(source.instruction, MAX_REWRITE_INSTRUCTION);
1142
+ if (source.action === "custom" && instruction) return { format, text: text2, change: instruction };
1143
+ throw new EmailGenerationError("NOTHING_TO_REWRITE", "Unknown action.");
1144
+ }
1145
+ function buildRewritePrompt({ format, text: text2, change }) {
1146
+ return [
1147
+ `Change: ${change}`,
1148
+ `Format: ${format === "plain" ? "plain text (a button label)" : "HTML (a text block)"}`,
1149
+ "",
1150
+ "<text>",
1151
+ text2,
1152
+ "</text>"
1153
+ ].join("\n");
1154
+ }
1155
+ function parseRewrite(answer) {
1156
+ const source = String(answer ?? "").trim();
1157
+ const unfenced = source.replace(/^```[a-z]*\s*\n?/i, "").replace(/\n?```\s*$/, "").trim();
1158
+ if (unfenced.startsWith("{")) {
1159
+ try {
1160
+ const data = JSON.parse(unfenced);
1161
+ if (typeof data?.text === "string") return data.text.trim();
1162
+ } catch {
1163
+ }
1164
+ }
1165
+ return unfenced.replace(/^<text>\s*/, "").replace(/\s*<\/text>$/, "");
1166
+ }
1167
+ var hrefsOf = (html) => [...html.matchAll(/<a\b[^>]*?\shref\s*=\s*(["'])(.*?)\1/gi)].map((match) => match[2].trim());
1168
+ var variableKeysOf = (html) => [...html.matchAll(/\{\{\s*([^}|]+?)\s*(?:\|[^}]*)?\}\}/g)].map((match) => match[1]);
1169
+ function reviewRewrite(original, rewritten, format) {
1170
+ const problems = [];
1171
+ if (!wordsOf(rewritten)) return ["The answer is empty."];
1172
+ const before = new Set(variableKeysOf(original));
1173
+ const after = new Set(variableKeysOf(rewritten));
1174
+ for (const key of before) if (!after.has(key)) problems.push(`The merge tag {{${key}}} is missing: keep it.`);
1175
+ for (const key of after) if (!before.has(key)) problems.push(`The merge tag {{${key}}} was added: remove it.`);
1176
+ if (format === "plain") return problems;
1177
+ const links = new Set(hrefsOf(original));
1178
+ const kept = new Set(hrefsOf(rewritten));
1179
+ for (const href of links) if (!kept.has(href)) problems.push(`The link to ${href} is missing: keep it with the same href.`);
1180
+ for (const href of kept) if (!links.has(href)) problems.push(`The link to ${href} was added: remove it.`);
1181
+ const wrapped = `<mjml><mj-body><mj-section><mj-column><mj-text>${rewritten}</mj-text></mj-column></mj-section></mj-body></mjml>`;
1182
+ for (const issue of validateMjml(wrapped).issues) problems.push(issue.message);
1183
+ return problems;
1184
+ }
1185
+ function createRewriter({ model, maxAttempts = 2 }) {
1186
+ return async function rewrite(input, { signal } = {}) {
1187
+ const request = cleanRewriteInput(input);
1188
+ const messages = [{ role: "user", content: buildRewritePrompt(request) }];
1189
+ let lastProblem = "";
1190
+ for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt += 1) {
1191
+ signal?.throwIfAborted();
1192
+ let answer;
1193
+ try {
1194
+ answer = await model({ system: REWRITE_SYSTEM_PROMPT, messages, schema: REWRITE_SCHEMA, signal, task: "rewrite" });
1195
+ } catch (error) {
1196
+ if (error instanceof EmailGenerationError && error.code === "REFUSED") throw new EmailGenerationError("REWRITE_REFUSED");
1197
+ throw error;
1198
+ }
1199
+ let text2 = parseRewrite(answer);
1200
+ if (request.format === "plain") text2 = wordsOf(text2).slice(0, 120);
1201
+ const problems = reviewRewrite(request.text, text2, request.format);
1202
+ if (!problems.length) return { text: text2, attempts: attempt };
1203
+ lastProblem = problems[0];
1204
+ messages.push(
1205
+ { role: "assistant", content: String(answer) },
1206
+ { role: "user", content: `Fix these problems and answer the whole text again:
1207
+ ${problems.map((p) => `- ${p}`).join("\n")}` }
1208
+ );
1209
+ }
1210
+ throw new EmailGenerationError("REWRITE_UNUSABLE", lastProblem);
1211
+ };
1212
+ }
1213
+
1214
+ // src/features/generate/subjects.js
1215
+ var SUBJECT_COUNT = 3;
1216
+ var MAX_SUBJECT_LENGTH = 150;
1217
+ var MAX_EMAIL_WORDS = 4e3;
1218
+ var MAX_VARIABLES = 60;
1219
+ var SUBJECTS_SCHEMA = {
1220
+ type: "object",
1221
+ properties: {
1222
+ subjects: {
1223
+ type: "array",
1224
+ description: "Exactly three subject lines, plain text.",
1225
+ items: { type: "string" }
1226
+ }
1227
+ },
1228
+ required: ["subjects"],
1229
+ additionalProperties: false
1230
+ };
1231
+ var SUBJECTS_SYSTEM_PROMPT = `You write email subject lines. You get an email (its current subject, its preview text and its words) and answer three NEW subject lines for it, in the JSON field "subjects".
1232
+
1233
+ Each subject:
1234
+ - tells what the email brings, specific to its content: under 50 characters when possible, 70 at most;
1235
+ - takes its own angle: one clear and direct, one that makes the reader curious, one personal or timely;
1236
+ - is written in the language of the email;
1237
+ - invents no facts: no price, discount, date, number, name or promise the email does not contain;
1238
+ - is plain text: no HTML, no quotation marks around it, no line break;
1239
+ - has no word in capitals, at most one "!", no spam wording ("free!!!", "act now", "100% guaranteed");
1240
+ - has no emoji, unless the current subject has one;
1241
+ - differs from the current subject and from the two others, and does not repeat the preview text.
1242
+
1243
+ Merge tags: you may personalise ONE of the three with a merge tag from the list you are given, written exactly as listed, e.g. {{contact.firstname}}. No other merge tag.`;
1244
+ var bounded2 = (value, max) => typeof value === "string" ? value.trim().slice(0, max) : "";
1245
+ var line = (value) => String(value ?? "").replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim().replace(/^["'«“”‘’]+|["'»“”‘’]+$/g, "").trim().slice(0, MAX_SUBJECT_LENGTH);
1246
+ var ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
1247
+ function emailWords(mjml) {
1248
+ return String(mjml || "").replace(/<mj-head[\s\S]*?<\/mj-head>/gi, " ").replace(/<(style|script|mj-style|mj-raw)[\s\S]*?<\/\1>/gi, " ").replace(/<\/(p|div|li|h[1-6]|mj-text|mj-button|mj-navbar-link|mj-social-element)>|<br\s*\/?>/gi, "\n").replace(/<[^>]*>/g, " ").replace(/&(amp|lt|gt|quot|apos|nbsp);/g, (_, name) => ENTITIES[
1249
+ /** @type {keyof typeof ENTITIES} */
1250
+ name
1251
+ ]).replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))).replace(/[ \t]+/g, " ").replace(/\s*\n\s*/g, "\n").trim().slice(0, MAX_EMAIL_WORDS);
1252
+ }
1253
+ function cleanSubjectsInput(input) {
1254
+ const source = input && typeof input === "object" ? input : {};
1255
+ const subject = line(source.subject);
1256
+ const preheader = bounded2(source.preheader, 300).replace(/\s+/g, " ");
1257
+ const text2 = bounded2(source.text, MAX_EMAIL_WORDS);
1258
+ if (!subject && !text2) throw new EmailGenerationError("NOTHING_TO_SUGGEST");
1259
+ const language = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(String(source.language || "")) ? String(source.language) : "en";
1260
+ const variables = (Array.isArray(source.variables) ? source.variables : DEFAULT_VARIABLES).filter((variable) => variable && /^[A-Za-z][\w.]*$/.test(String(variable.key)) && variable.key !== "unsubscribe").filter((variable) => (
1261
+ /** @type {any} */
1262
+ variable.kind !== "link"
1263
+ )).slice(0, MAX_VARIABLES).map((variable) => ({ key: String(variable.key), label: bounded2(variable.label, 80) }));
1264
+ const avoid = (Array.isArray(source.avoid) ? source.avoid : []).map(line).filter(Boolean).slice(0, 12);
1265
+ return { subject, preheader, text: text2, language, variables, avoid };
1266
+ }
1267
+ function buildSubjectsPrompt({ subject, preheader, text: text2, language, variables, avoid }) {
1268
+ return [
1269
+ `Current subject: ${subject || "(none yet)"}`,
1270
+ `Preview text: ${preheader || "(none)"}`,
1271
+ avoid.length ? `Already suggested, do not repeat: ${avoid.map((idea) => `"${idea}"`).join(", ")}` : null,
1272
+ `Merge tags you may use: ${variables.length ? variables.map((v) => `{{${v.key}}}${v.label ? ` (${v.label})` : ""}`).join(", ") : "none"}`,
1273
+ text2 ? null : `The email has no words yet: write in ${languageName(language)}.`,
1274
+ "",
1275
+ "<email>",
1276
+ text2,
1277
+ "</email>"
1278
+ ].filter((row) => row !== null).join("\n");
1279
+ }
1280
+ var capitalized = (subject) => {
1281
+ const first = subject.split(/\s/)[0];
1282
+ return /^\p{Ll}/u.test(first) && !/\p{Lu}/u.test(first) ? subject[0].toLocaleUpperCase() + subject.slice(1) : subject;
1283
+ };
1284
+ function parseSubjects(answer) {
1285
+ const source = String(answer ?? "").trim();
1286
+ const unfenced = source.replace(/^```[a-z]*\s*\n?/i, "").replace(/\n?```\s*$/, "").trim();
1287
+ const start = unfenced.indexOf("{");
1288
+ const end = unfenced.lastIndexOf("}");
1289
+ if (start !== -1 && end > start) {
1290
+ try {
1291
+ const data = JSON.parse(unfenced.slice(start, end + 1));
1292
+ if (Array.isArray(data?.subjects)) return data.subjects.map((subject) => capitalized(line(subject)));
1293
+ } catch {
1294
+ }
1295
+ }
1296
+ return unfenced.split("\n").map((row) => capitalized(line(row.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, ""))));
1297
+ }
1298
+ var keysOf = (text2) => [...text2.matchAll(/\{\{\s*([^}|]+?)\s*(?:\|[^}]*)?\}\}/g)].map((match) => match[1]);
1299
+ function reviewSubjects(subjects, request) {
1300
+ const known = new Set(request.variables.map((variable) => variable.key));
1301
+ const seen = new Set([request.subject, ...request.avoid].map((subject) => subject.toLocaleLowerCase()));
1302
+ const usable = [];
1303
+ const problems = [];
1304
+ for (const subject of subjects) {
1305
+ if (!subject) continue;
1306
+ const unknown = keysOf(subject).filter((key) => !known.has(key));
1307
+ if (unknown.length) {
1308
+ problems.push(`"${subject}" uses {{${unknown[0]}}}, which is not in the list of merge tags.`);
1309
+ continue;
1310
+ }
1311
+ const folded = subject.toLocaleLowerCase();
1312
+ if (seen.has(folded)) {
1313
+ problems.push(`"${subject}" repeats the current subject or another one.`);
1314
+ continue;
1315
+ }
1316
+ seen.add(folded);
1317
+ usable.push(subject);
1318
+ }
1319
+ if (usable.length < SUBJECT_COUNT && !problems.length) problems.push(`Give exactly ${SUBJECT_COUNT} subjects.`);
1320
+ return { usable: usable.slice(0, SUBJECT_COUNT), problems: usable.length >= SUBJECT_COUNT ? [] : problems };
1321
+ }
1322
+ function createSubjectSuggester({ model, maxAttempts = 2 }) {
1323
+ return async function suggestSubjects(input, { signal } = {}) {
1324
+ const request = cleanSubjectsInput(input);
1325
+ const messages = [{ role: "user", content: buildSubjectsPrompt(request) }];
1326
+ let best = [];
1327
+ for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt += 1) {
1328
+ signal?.throwIfAborted();
1329
+ let answer;
1330
+ try {
1331
+ answer = await model({ system: SUBJECTS_SYSTEM_PROMPT, messages, schema: SUBJECTS_SCHEMA, signal, task: "subjects" });
1332
+ } catch (error) {
1333
+ if (error instanceof EmailGenerationError && error.code === "REFUSED") throw new EmailGenerationError("SUBJECTS_REFUSED");
1334
+ throw error;
1335
+ }
1336
+ const { usable, problems } = reviewSubjects(parseSubjects(answer), request);
1337
+ if (usable.length > best.length) best = usable;
1338
+ if (!problems.length) return { subjects: usable, attempts: attempt };
1339
+ messages.push(
1340
+ { role: "assistant", content: String(answer) },
1341
+ { role: "user", content: `Fix these problems and answer the three subjects again:
1342
+ ${problems.map((p) => `- ${p}`).join("\n")}` }
1343
+ );
1344
+ }
1345
+ if (best.length) return { subjects: best, attempts: Math.max(1, maxAttempts) };
1346
+ throw new EmailGenerationError("SUBJECTS_UNUSABLE");
1347
+ };
1348
+ }
1349
+
1350
+ // src/features/generate/generator.js
1351
+ var MAX_DETAILS_LENGTH = 2e3;
1352
+ var MAX_VARIABLES2 = 200;
1353
+ var text = (value, max) => typeof value === "string" ? value.trim().slice(0, max) : "";
1354
+ function cleanGenerateInput(input) {
1355
+ const source = input && typeof input === "object" ? input : {};
1356
+ const purpose2 = purposeById(typeof source.purpose === "string" ? source.purpose : null);
1357
+ const details = text(source.details, MAX_DETAILS_LENGTH);
1358
+ const language = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(String(source.language || "")) ? String(source.language) : "en";
1359
+ const sender = source.sender && typeof source.sender === "object" ? source.sender : {};
1360
+ const logoUrl = text(sender.logoUrl, 2e3);
1361
+ const variables = (Array.isArray(source.variables) ? source.variables : DEFAULT_VARIABLES).filter((variable) => variable && /^[A-Za-z][\w.]*$/.test(String(variable.key))).slice(0, MAX_VARIABLES2).map((variable) => ({ key: String(variable.key), label: text(variable.label, 80) }));
1362
+ const { primary, heading, font } = cleanBrand(source.brand);
1363
+ return {
1364
+ purpose: purpose2,
1365
+ details,
1366
+ language,
1367
+ sender: {
1368
+ name: text(sender.name, 120),
1369
+ address: text(sender.address, 300),
1370
+ logoUrl: logoUrl && /^https:\/\//i.test(logoUrl) && isSafeUrl(logoUrl, "image") ? logoUrl : ""
1371
+ },
1372
+ brand: { ...primary ? { primary } : {}, ...heading ? { heading } : {}, ...font ? { font } : {} },
1373
+ variables
1374
+ };
1375
+ }
1376
+ function createEmailGenerator({ model, maxAttempts = 2, compile } = (
1377
+ /** @type {any} */
1378
+ {}
1379
+ )) {
1380
+ if (typeof model !== "function") throw new Error("createEmailGenerator: `model` must be a function");
1381
+ async function generate(input, { signal } = {}) {
1382
+ const request = cleanGenerateInput(input);
1383
+ if (!request.purpose && !request.details) {
1384
+ throw new EmailGenerationError("NOTHING_TO_GENERATE");
1385
+ }
1386
+ const kind = request.purpose?.kind || "marketing";
1387
+ const keepImages = request.sender.logoUrl ? [request.sender.logoUrl] : [];
1388
+ const messages = [{ role: "user", content: buildUserPrompt(request) }];
1389
+ let usable = null;
1390
+ let lastProblem = "";
1391
+ for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt += 1) {
1392
+ signal?.throwIfAborted();
1393
+ const answer = await model({ system: SYSTEM_PROMPT, messages, schema: EMAIL_DRAFT_SCHEMA, signal, task: "generate" });
1394
+ let problems;
1395
+ try {
1396
+ const draft = parseDraft(answer);
1397
+ draft.mjml = normalizeGeneratedMjml(draft.mjml, { variables: request.variables, keepImages });
1398
+ draft.mjml = applyBrand(draft.mjml, request.brand);
1399
+ const compileWarnings = compile ? (await compile(draft.mjml)).warnings || [] : [];
1400
+ const { blocking, advice } = reviewDraft(draft, { kind, compileWarnings });
1401
+ if (!blocking.length) {
1402
+ usable = { ...draft, warnings: advice, attempts: attempt };
1403
+ if (!advice.length) return usable;
1404
+ }
1405
+ problems = [...blocking, ...advice];
1406
+ } catch (error) {
1407
+ if (!(error instanceof EmailGenerationError)) throw error;
1408
+ problems = [error.detail];
1409
+ }
1410
+ lastProblem = problems[0];
1411
+ messages.push({ role: "assistant", content: String(answer) }, { role: "user", content: buildRepairPrompt(problems) });
1412
+ }
1413
+ if (usable) return usable;
1414
+ throw new EmailGenerationError("INVALID_OUTPUT", lastProblem);
1415
+ }
1416
+ return {
1417
+ generate,
1418
+ rewrite: createRewriter({ model, maxAttempts }),
1419
+ suggestSubjects: createSubjectSuggester({ model, maxAttempts })
1420
+ };
1421
+ }
1422
+
1423
+ // src/features/brand/extract.js
1424
+ function decodeEntities2(text2) {
1425
+ return String(text2 ?? "").replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16))).replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec))).replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&");
1426
+ }
1427
+ function tagAttributes(tag) {
1428
+ const attributes = {};
1429
+ const inner = tag.replace(/^<[^\s>/]+/, "").replace(/\/?>$/, "");
1430
+ const pattern = /([^\s"'=<>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
1431
+ let match;
1432
+ while (match = pattern.exec(inner)) {
1433
+ const name = match[1].toLowerCase();
1434
+ if (!(name in attributes)) attributes[name] = decodeEntities2(match[2] ?? match[3] ?? match[4] ?? "");
1435
+ }
1436
+ return attributes;
1437
+ }
1438
+ var startTags = (html, name) => html.match(new RegExp(`<${name}\\b[^>]*>`, "gi")) || [];
1439
+ function resolve(href, base) {
1440
+ const value = String(href ?? "").trim();
1441
+ if (!value || /^(data|javascript|blob):/i.test(value)) return "";
1442
+ try {
1443
+ const url = new URL(value, base);
1444
+ return url.protocol === "https:" || url.protocol === "http:" ? url.href : "";
1445
+ } catch {
1446
+ return "";
1447
+ }
1448
+ }
1449
+ function largestOf(srcset) {
1450
+ let best = { url: "", size: 0 };
1451
+ for (const part of srcset.split(/,\s+/)) {
1452
+ const [url, descriptor = "1x"] = part.trim().split(/\s+/);
1453
+ const size = parseFloat(descriptor) * (descriptor.endsWith("w") ? 1 : 1e3);
1454
+ if (url && size > best.size) best = { url, size };
1455
+ }
1456
+ return best.url;
1457
+ }
1458
+ function jsonLdNodes(value) {
1459
+ if (Array.isArray(value)) return value.flatMap(jsonLdNodes);
1460
+ if (!value || typeof value !== "object") return [];
1461
+ const node = (
1462
+ /** @type {any} */
1463
+ value
1464
+ );
1465
+ return [node, ...jsonLdNodes(node["@graph"]), ...jsonLdNodes(node.publisher), ...jsonLdNodes(node.brand)];
1466
+ }
1467
+ var ORGANIZATION = /organization|corporation|business|brand|store|restaurant|company|ngo|school|airline|bank|agency/i;
1468
+ function hostWords(host) {
1469
+ const labels = host.toLowerCase().replace(/^www\d?\./, "").split(".");
1470
+ const main = labels.length > 2 && labels[labels.length - 2].length <= 3 ? labels[labels.length - 3] : labels[0];
1471
+ return main.replace(/[-_]+/g, " ");
1472
+ }
1473
+ var squash = (text2) => text2.toLowerCase().replace(/[^a-z0-9À-￿]+/g, "");
1474
+ var cleanName = (value) => decodeEntities2(String(value ?? "")).replace(/\s+/g, " ").trim().slice(0, 80);
1475
+ function readPage(html, pageUrl) {
1476
+ const source = String(html ?? "");
1477
+ const head = source.slice(0, 4e5);
1478
+ const baseTag = startTags(head, "base").map(tagAttributes).find((a) => a.href);
1479
+ const base = resolve(baseTag?.href, pageUrl) || pageUrl;
1480
+ const host = (() => {
1481
+ try {
1482
+ return new URL(pageUrl).hostname;
1483
+ } catch {
1484
+ return "";
1485
+ }
1486
+ })();
1487
+ const meta = {};
1488
+ for (const attributes of startTags(head, "meta").map(tagAttributes)) {
1489
+ const key = (attributes.property || attributes.name || "").toLowerCase();
1490
+ if (key && attributes.content && !(key in meta)) meta[key] = attributes.content;
1491
+ }
1492
+ let schemaName = "";
1493
+ let schemaLogo = "";
1494
+ for (const [, body] of source.matchAll(/<script\b[^>]*type\s*=\s*["']?application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)) {
1495
+ let data;
1496
+ try {
1497
+ data = JSON.parse(body.trim());
1498
+ } catch {
1499
+ continue;
1500
+ }
1501
+ for (const node of jsonLdNodes(data)) {
1502
+ const types = [].concat(node["@type"] || []).join(" ");
1503
+ if (!ORGANIZATION.test(types)) continue;
1504
+ if (!schemaName && typeof node.name === "string") schemaName = node.name;
1505
+ const logo = typeof node.logo === "string" ? node.logo : node.logo?.url || node.logo?.contentUrl || "";
1506
+ if (!schemaLogo && typeof logo === "string") schemaLogo = resolve(logo, base);
1507
+ }
1508
+ }
1509
+ const title = cleanName(head.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1] || "");
1510
+ const words = hostWords(host);
1511
+ const fromTitle = title.split(/\s+[|–—:·•»-]\s+/).map((part) => part.trim()).find((part) => part && squash(part).length <= 40 && (squash(part).includes(squash(words)) || squash(words).includes(squash(part)))) || "";
1512
+ const fromHost = words.replace(/\b\p{L}/gu, (letter) => letter.toUpperCase());
1513
+ const name = cleanName(meta["og:site_name"] || schemaName || meta["application-name"] || fromTitle || fromHost);
1514
+ const logos = [];
1515
+ const add = (url, from) => {
1516
+ if (url && !logos.some((logo) => logo.url === url)) logos.push({ url, from });
1517
+ };
1518
+ add(schemaLogo, "schema");
1519
+ const images = [];
1520
+ const headerEnd = (() => {
1521
+ const at = source.search(/<\/header>/i);
1522
+ return at > 0 ? at : Math.min(source.length, 2e4);
1523
+ })();
1524
+ for (const match of source.matchAll(/<img\b[^>]*>/gi)) {
1525
+ const attributes = tagAttributes(match[0]);
1526
+ const own = [attributes.class, attributes.id, attributes.alt, attributes.src, attributes["data-src"]].join(" ").toLowerCase();
1527
+ const before = source.slice(Math.max(0, (match.index ?? 0) - 400), match.index).toLowerCase();
1528
+ const parent = /<(a|div|span|figure|picture)\b[^>]*(class|id)\s*=\s*["'][^"']*logo[^"']*["'][^>]*>[^<]*(<source\b[^>]*>\s*)*$/i.test(before);
1529
+ if (!own.includes("logo") && !parent) continue;
1530
+ if (/partner|client|customer|sponsor|award|payment|badge|trust|press|app-?store|google-?play/.test(own)) continue;
1531
+ const url = resolve(attributes.srcset ? largestOf(attributes.srcset) : attributes.src || attributes["data-src"] || "", base);
1532
+ if (!url) continue;
1533
+ let score = own.includes("logo") ? 3 : 2;
1534
+ if ((match.index ?? 0) < headerEnd) score += 3;
1535
+ if (name && squash(attributes.alt || "").includes(squash(name))) score += 2;
1536
+ if (/footer/.test(own)) score -= 2;
1537
+ images.push({ url, score: score - images.length * 0.01 });
1538
+ }
1539
+ images.sort((a, b) => b.score - a.score).forEach((image) => add(image.url, "img"));
1540
+ const icons = [];
1541
+ for (const attributes of startTags(head, "link").map(tagAttributes)) {
1542
+ const rel = (attributes.rel || "").toLowerCase();
1543
+ if (!/apple-touch-icon|icon/.test(rel)) continue;
1544
+ const size = parseInt(attributes.sizes || (rel.includes("apple") ? "180" : "0"), 10) || 0;
1545
+ if (size >= 96) icons.push({ url: resolve(attributes.href, base), size });
1546
+ }
1547
+ icons.sort((a, b) => b.size - a.size).forEach((icon) => add(icon.url, "icon"));
1548
+ const stylesheets = startTags(head, "link").map(tagAttributes).filter((a) => /(^|\s)stylesheet(\s|$)/i.test(a.rel || "") && !/^print$/i.test((a.media || "").trim())).map((a) => resolve(a.href, base)).filter(Boolean).filter((href) => !/fonts\.googleapis|use\.typekit|fontawesome|cookie|consent|onetrust/i.test(href));
1549
+ const css = [
1550
+ ...[...source.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]),
1551
+ // Inline styles of buttons and links count too: `<a class="btn" style="…">`.
1552
+ ...[...source.matchAll(/<(a|button)\b[^>]*>/gi)].map((m) => tagAttributes(m[0])).filter((a) => a.style).map((a) => `${/btn|button|cta/i.test(a.class || "") ? ".btn" : "a"}{${a.style}}`)
1553
+ ].join("\n");
1554
+ return {
1555
+ name,
1556
+ logos,
1557
+ stylesheets: [...new Set(stylesheets)],
1558
+ css,
1559
+ themeColor: meta["theme-color"] || meta["msapplication-tilecolor"] || ""
1560
+ };
1561
+ }
1562
+ var PRIMARY_NAME = /primary|brand|accent|main|theme|highlight|cta/i;
1563
+ var NOT_A_BRAND_NAME = /text|foreground|fg|border|muted|gr[ae]y|neutral|surface|shadow|overlay|success|error|danger|warning|info|disabled|placeholder/i;
1564
+ var NOISE = /error|danger|success|warning|alert|invalid|valid\b|notice|toast|badge|disabled|facebook|twitter|linkedin|instagram|youtube|whatsapp|pinterest|tiktok|social|cookie|consent|onetrust|ot-|cmp|gdpr|wp-block|has-[\w-]+-(background-)?color|syntax|hljs|code/i;
1565
+ var COLOR_IN_VALUE = /#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/gi;
1566
+ var COLOR_PROPERTY = /^(color|background|background-color|border|border-color|border-(top|bottom|left|right)(-color)?|fill|stroke|outline(-color)?|text-decoration-color|accent-color|caret-color)$/;
1567
+ var stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, "");
1568
+ function rulesOf(css) {
1569
+ const rules = [];
1570
+ for (const [, selector, body] of stripComments(css).matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
1571
+ const declarations = body.split(";").map((part) => {
1572
+ const colon = part.indexOf(":");
1573
+ return (
1574
+ /** @type {[string, string]} */
1575
+ [part.slice(0, colon).trim().toLowerCase(), part.slice(colon + 1).trim()]
1576
+ );
1577
+ }).filter(([property, value]) => property && value);
1578
+ if (declarations.length) rules.push([selector.trim(), declarations]);
1579
+ }
1580
+ return rules;
1581
+ }
1582
+ function customColor(value) {
1583
+ const direct = parseColor(value.replace(/\s*!important$/, ""));
1584
+ if (direct) return direct;
1585
+ if (/^\d{1,3}(\.\d+)?\s+\d{1,3}(\.\d+)?\s+\d{1,3}(\.\d+)?$/.test(value)) return parseColor(`rgb(${value})`);
1586
+ if (/^\d{1,3}(\.\d+)?(deg)?\s+\d{1,3}(\.\d+)?%\s+\d{1,3}(\.\d+)?%$/.test(value)) return parseColor(`hsl(${value.replace("deg", "")})`);
1587
+ return null;
1588
+ }
1589
+ function resolveVars(value, custom) {
1590
+ let out = value;
1591
+ for (let depth = 0; depth < 4 && out.includes("var("); depth += 1) {
1592
+ out = out.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\))?[^()]*))?\)/g, (_, name, fallback) => custom.get(name) ?? fallback ?? "");
1593
+ }
1594
+ return out;
1595
+ }
1596
+ var distance = (a, b) => Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b);
1597
+ var colorsIn = (value) => (value.match(COLOR_IN_VALUE) || []).map((text2) => parseColor(text2)).filter((c) => c && c.a >= 0.6);
1598
+ function nearestFont(family) {
1599
+ const stack = String(family ?? "").toLowerCase().replace(/["']/g, "");
1600
+ if (!stack.trim() || /^(inherit|initial|unset|revert)$/.test(stack.trim())) return void 0;
1601
+ const first = stack.split(",")[0].trim();
1602
+ const known = FONT_OPTIONS.find((option) => first.startsWith(option.label.toLowerCase()));
1603
+ if (known) return known.value;
1604
+ if (/^helvetica/.test(first)) return FONT_OPTIONS[0].value;
1605
+ if (/^times/.test(first)) return FONT_OPTIONS.find((o) => o.label === "Times")?.value;
1606
+ if (/monospace|mono\b|courier|consolas/.test(stack)) return FONT_OPTIONS.find((o) => o.label === "Courier")?.value;
1607
+ if (/(^|,)\s*serif\s*(,|$)|garamond|playfair|merriweather|lora|baskerville|caslon|didot|bodoni|crimson|libre baskerville|source serif|noto serif|pt serif/.test(stack)) {
1608
+ return FONT_OPTIONS.find((o) => o.label === "Georgia")?.value;
1609
+ }
1610
+ return FONT_OPTIONS[0].value;
1611
+ }
1612
+ function brandFromCss({ css, themeColor = "" }) {
1613
+ const rules = rulesOf(String(css ?? ""));
1614
+ const custom = /* @__PURE__ */ new Map();
1615
+ for (const [selector, declarations] of rules) {
1616
+ const root = /^(:root|html|body|\*)(\s*,|$)/i.test(selector);
1617
+ for (const [property, value] of declarations) {
1618
+ if (property.startsWith("--") && (root || !custom.has(property))) custom.set(property, value.replace(/\s*!important$/, ""));
1619
+ }
1620
+ }
1621
+ const found = [];
1622
+ const darkText = [];
1623
+ const fonts = [];
1624
+ const count = (color, weight) => found.push({ color, weight });
1625
+ for (const [selector, declarations] of rules) {
1626
+ if (selector.startsWith("@")) continue;
1627
+ const noise = NOISE.test(selector);
1628
+ const emphasis = /btn|button|cta|primary|brand|accent|hero|highlight/i.test(selector) ? 4 : /(^|[\s,>+~])a([:.[\s,]|$)|link/i.test(selector) ? 2 : 1;
1629
+ const isHeading = /(^|[\s,>])h[1-3]\b|title|heading|headline/i.test(selector);
1630
+ const isBody = /^(html|body|:root)(\s*,|$)|(^|,)\s*(html|body)\s*(,|$)/i.test(selector);
1631
+ for (const [property, raw] of declarations) {
1632
+ if (property.startsWith("--")) {
1633
+ const color = customColor(resolveVars(raw, custom));
1634
+ if (color && color.a >= 0.6 && PRIMARY_NAME.test(property) && !NOT_A_BRAND_NAME.test(property)) count(color, 10);
1635
+ continue;
1636
+ }
1637
+ const value = resolveVars(raw, custom);
1638
+ if (property === "font-family" || property === "font") {
1639
+ const family = property === "font" ? value.replace(/^.*?\d[\w%.]*(\/[\w%.]+)?\s+/, "") : value;
1640
+ if (isBody || isHeading) fonts.push({ font: family, weight: isBody ? 2 : 1 });
1641
+ continue;
1642
+ }
1643
+ if (!COLOR_PROPERTY.test(property) || noise) continue;
1644
+ for (const color of colorsIn(value)) {
1645
+ if (!color) continue;
1646
+ count(color, emphasis * (property.startsWith("background") ? 1.5 : 1));
1647
+ if (property === "color" && (isHeading || isBody)) darkText.push({ color, weight: isHeading ? 2 : 1 });
1648
+ }
1649
+ }
1650
+ }
1651
+ const theme = parseColor(themeColor);
1652
+ if (theme && theme.a >= 0.6) count(theme, 15);
1653
+ const clusters = [];
1654
+ for (const entry of found) {
1655
+ const near = clusters.find((cluster) => distance(cluster.color, entry.color) < 24);
1656
+ if (near) near.weight += entry.weight;
1657
+ else clusters.push({ ...entry });
1658
+ }
1659
+ clusters.sort((a, b) => b.weight - a.weight);
1660
+ const primary = clusters.find(({ color }) => {
1661
+ const { s, l } = toHsl(color);
1662
+ return s >= 0.25 && l >= 0.15 && l <= 0.85;
1663
+ });
1664
+ const result = {};
1665
+ if (primary) result.primary = toHex(primary.color);
1666
+ const dark = darkText.filter(({ color }) => toHsl(color).l < 0.3).reduce((map, { color, weight }) => map.set(toHex(color), (map.get(toHex(color)) || 0) + weight), /* @__PURE__ */ new Map());
1667
+ const heading = [...dark.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
1668
+ if (heading) result.heading = heading;
1669
+ else if (result.primary && contrastRatio(result.primary, PALETTE.white) >= 7) result.heading = result.primary;
1670
+ else result.heading = PALETTE.ink;
1671
+ const font = fonts.sort((a, b) => b.weight - a.weight).map(({ font: font2 }) => nearestFont(font2)).find(Boolean);
1672
+ if (font) result.font = font;
1673
+ return result;
1674
+ }
1675
+ function imageSize(bytes) {
1676
+ if (!bytes || bytes.length < 24) return null;
1677
+ const at = (i) => bytes[i];
1678
+ if (at(0) === 137 && at(1) === 80 && at(2) === 78 && at(3) === 71) {
1679
+ const read32 = (i) => (at(i) << 24 | at(i + 1) << 16 | at(i + 2) << 8 | at(i + 3)) >>> 0;
1680
+ return { width: read32(16), height: read32(20) };
1681
+ }
1682
+ if (at(0) === 71 && at(1) === 73 && at(2) === 70) {
1683
+ return { width: at(6) | at(7) << 8, height: at(8) | at(9) << 8 };
1684
+ }
1685
+ if (at(0) === 255 && at(1) === 216) {
1686
+ let i = 2;
1687
+ while (i + 9 < bytes.length) {
1688
+ if (at(i) !== 255) {
1689
+ i += 1;
1690
+ continue;
1691
+ }
1692
+ const marker = at(i + 1);
1693
+ if (marker === 216 || marker === 1 || marker >= 208 && marker <= 215 || marker === 255) {
1694
+ i += marker === 255 ? 1 : 2;
1695
+ continue;
1696
+ }
1697
+ const length = at(i + 2) << 8 | at(i + 3);
1698
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
1699
+ return { height: at(i + 5) << 8 | at(i + 6), width: at(i + 7) << 8 | at(i + 8) };
1700
+ }
1701
+ i += 2 + length;
1702
+ }
1703
+ }
1704
+ return null;
1705
+ }
1706
+ function logoWidthFor({ width, height }) {
1707
+ if (!width || !height) return 140;
1708
+ const ratio = width / height;
1709
+ const shown = width >= 240 ? width / 2 : width;
1710
+ const target = ratio >= 2.5 ? 180 : ratio >= 1.3 ? 140 : 72;
1711
+ return Math.max(24, Math.round(Math.min(target, shown)));
1712
+ }
1713
+
1714
+ // src/features/brand/messages.js
1715
+ var BRAND_IMPORT_ERRORS = {
1716
+ BAD_URL: [400, "Enter the address of a website, like acme.com"],
1717
+ PRIVATE_ADDRESS: [400, "This address is not a public website"],
1718
+ UNREACHABLE: [422, "The website could not be reached. Check the address."],
1719
+ TIMEOUT: [422, "The website took too long to answer"],
1720
+ HTTP_ERROR: [422, "The website does not let the builder read it. Fill in your brand below."],
1721
+ NOT_HTML: [422, "This address is not a web page"],
1722
+ TOO_MANY_REDIRECTS: [422, "The website redirects too many times"],
1723
+ TOO_LARGE: [422, "The website sent too much data"]
1724
+ };
1725
+ var BRAND_NOTES = {
1726
+ LOGO_NOT_FOUND: "No logo was found on the website. Upload it below.",
1727
+ LOGO_UNSUPPORTED: "The logo on the website is an SVG or WebP image, which most email apps do not show. Upload a PNG or JPEG version.",
1728
+ LOGO_ICON: "The website icon is used as the logo. Upload your full logo if you have one.",
1729
+ COLORS_NOT_FOUND: "No main color was found on the website. Pick one below."
1730
+ };
1731
+
1732
+ export {
1733
+ LONGHAND_SPACING,
1734
+ parseSpacing,
1735
+ formatSpacing,
1736
+ toAttributes,
1737
+ toValues,
1738
+ LINK_ATTRIBUTES,
1739
+ IMAGE_ATTRIBUTES,
1740
+ isSafeUrl,
1741
+ INLINE_TAGS,
1742
+ validateMjml,
1743
+ assertValidMjml,
1744
+ GENERATION_ERRORS,
1745
+ EmailGenerationError,
1746
+ parseDraft,
1747
+ normalizeGeneratedMjml,
1748
+ reviewDraft,
1749
+ EMAIL_DRAFT_SCHEMA,
1750
+ SYSTEM_PROMPT,
1751
+ buildUserPrompt,
1752
+ PURPOSE_CATEGORIES,
1753
+ EMAIL_PURPOSES,
1754
+ purposeById,
1755
+ purposesByCategory,
1756
+ REWRITE_ACTIONS,
1757
+ REWRITE_LANGUAGES,
1758
+ MAX_REWRITE_INSTRUCTION,
1759
+ REWRITE_SYSTEM_PROMPT,
1760
+ reviewRewrite,
1761
+ createRewriter,
1762
+ SUBJECTS_SYSTEM_PROMPT,
1763
+ emailWords,
1764
+ reviewSubjects,
1765
+ createSubjectSuggester,
1766
+ MAX_DETAILS_LENGTH,
1767
+ cleanGenerateInput,
1768
+ createEmailGenerator,
1769
+ readPage,
1770
+ brandFromCss,
1771
+ imageSize,
1772
+ logoWidthFor,
1773
+ BRAND_IMPORT_ERRORS,
1774
+ BRAND_NOTES
1775
+ };
1776
+ //# sourceMappingURL=chunk-JNHFZXDL.js.map