best-email-builder 0.5.0 → 0.6.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 (54) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +50 -10
  3. package/dist/ai-server.js +6 -4
  4. package/dist/ai.js +167 -39
  5. package/dist/ai.js.map +1 -1
  6. package/dist/builder.js +9 -9
  7. package/dist/{chunk-ECHGJZ2W.js → chunk-4U7L2NYQ.js} +32 -3
  8. package/dist/chunk-4U7L2NYQ.js.map +1 -0
  9. package/dist/{chunk-6D3SLT5W.js → chunk-5W4TUUPD.js} +4 -4
  10. package/dist/{chunk-LYMJ2TBX.js → chunk-CNJZLZRH.js} +2 -2
  11. package/dist/{chunk-DFNHICTF.js → chunk-JI36FNWE.js} +3 -1
  12. package/dist/chunk-JI36FNWE.js.map +1 -0
  13. package/dist/{chunk-LXR4QTGP.js → chunk-JU3OUO6X.js} +3 -3
  14. package/dist/{chunk-PB7YZ3WP.js → chunk-NC2YDZSP.js} +19 -65
  15. package/dist/chunk-NC2YDZSP.js.map +1 -0
  16. package/dist/{chunk-MPX5CWTZ.js → chunk-NZIQF7IW.js} +172 -29
  17. package/dist/chunk-NZIQF7IW.js.map +1 -0
  18. package/dist/{chunk-2RZHGAH5.js → chunk-OWVXKMLI.js} +59 -2
  19. package/dist/chunk-OWVXKMLI.js.map +1 -0
  20. package/dist/{chunk-7U6BAKM7.js → chunk-QPAZLCPO.js} +3 -3
  21. package/dist/{chunk-7U6BAKM7.js.map → chunk-QPAZLCPO.js.map} +1 -1
  22. package/dist/{chunk-KXJJSVN5.js → chunk-SAIW46IC.js} +2 -2
  23. package/dist/{chunk-5SASKCUC.js → chunk-W424KPCR.js} +2 -2
  24. package/dist/{chunk-3LKF4QBL.js → chunk-XEFBPP5Y.js} +4 -4
  25. package/dist/index.js +11 -11
  26. package/dist/library.js +5 -5
  27. package/dist/server.js +12 -29
  28. package/dist/server.js.map +1 -1
  29. package/dist/styles.css +1 -1
  30. package/dist/types/ai/ConnectAi.d.ts +4 -0
  31. package/dist/types/ai/claude.d.ts +30 -5
  32. package/dist/types/ai/createAi.d.ts +5 -3
  33. package/dist/types/ai/draft.d.ts +12 -0
  34. package/dist/types/ai/index.d.ts +2 -2
  35. package/dist/types/ai/keys.d.ts +15 -26
  36. package/dist/types/ai/providers.d.ts +24 -2
  37. package/dist/types/ai/server.d.ts +1 -1
  38. package/dist/types/core/constants.d.ts +1 -1
  39. package/dist/types/engine/NativeAdapter.d.ts +1 -1
  40. package/dist/types/renderer/prepareMjml.d.ts +8 -2
  41. package/dist/types/server/index.d.ts +1 -1
  42. package/dist/types/server/render.d.ts +5 -2
  43. package/package.json +1 -1
  44. package/dist/chunk-2RZHGAH5.js.map +0 -1
  45. package/dist/chunk-DFNHICTF.js.map +0 -1
  46. package/dist/chunk-ECHGJZ2W.js.map +0 -1
  47. package/dist/chunk-MPX5CWTZ.js.map +0 -1
  48. package/dist/chunk-PB7YZ3WP.js.map +0 -1
  49. /package/dist/{chunk-6D3SLT5W.js.map → chunk-5W4TUUPD.js.map} +0 -0
  50. /package/dist/{chunk-LYMJ2TBX.js.map → chunk-CNJZLZRH.js.map} +0 -0
  51. /package/dist/{chunk-LXR4QTGP.js.map → chunk-JU3OUO6X.js.map} +0 -0
  52. /package/dist/{chunk-KXJJSVN5.js.map → chunk-SAIW46IC.js.map} +0 -0
  53. /package/dist/{chunk-5SASKCUC.js.map → chunk-W424KPCR.js.map} +0 -0
  54. /package/dist/{chunk-3LKF4QBL.js.map → chunk-XEFBPP5Y.js.map} +0 -0
@@ -1,22 +1,28 @@
1
1
  import {
2
2
  LONGHAND_SPACING,
3
+ MJML_DEFAULTS,
3
4
  SIDES,
4
5
  parseSpacing
5
- } from "./chunk-2RZHGAH5.js";
6
+ } from "./chunk-OWVXKMLI.js";
6
7
  import {
7
8
  isSafeUrl,
8
9
  validateMjml
9
- } from "./chunk-5SASKCUC.js";
10
+ } from "./chunk-W424KPCR.js";
10
11
  import {
11
12
  DEFAULT_VARIABLES,
12
13
  FONT_OPTIONS,
13
14
  PALETTE,
14
15
  applyBrand,
15
16
  cleanBrand,
17
+ contrastRatio,
18
+ createId,
16
19
  createNode,
20
+ parseColor,
17
21
  parseMjml,
22
+ parseMjmlFragment,
23
+ readableOn,
18
24
  serializeMjml
19
- } from "./chunk-DFNHICTF.js";
25
+ } from "./chunk-JI36FNWE.js";
20
26
 
21
27
  // src/ai/draft.js
22
28
  var GENERATION_ERRORS = {
@@ -113,6 +119,11 @@ function cleanAttributes(node, keepImages) {
113
119
  if (unitless && (PIXEL_ATTRIBUTES.has(name) || /^padding-/.test(name))) attributes[name] = `${value.trim()}px`;
114
120
  }
115
121
  if (node.tag === "mj-image" && attributes.src !== void 0 && !keepImages.has(attributes.src.trim())) attributes.src = "";
122
+ if (node.tag === "mj-button") {
123
+ const fill = attributes["background-color"] ?? MJML_DEFAULTS["mj-button"]["background-color"];
124
+ const words = attributes.color ?? MJML_DEFAULTS["mj-button"].color;
125
+ if (parseColor(fill) && parseColor(words) && contrastRatio(fill, words) < 3) attributes.color = readableOn(fill);
126
+ }
116
127
  delete attributes["background-url"];
117
128
  return attributes;
118
129
  }
@@ -133,6 +144,30 @@ function wrapLoose(children, isLoose, wrap) {
133
144
  flush();
134
145
  return out;
135
146
  }
147
+ var MJML_IN_HTML = /<mj-image\b(?:[^>"']|"[^"]*"|'[^']*')*?(?:\/>|>[\s\S]*?<\/mj-image\s*>)|<\/?mj-[a-z-]*\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
148
+ function liftImages(node, attributes, keepImages) {
149
+ const content = String(node.content);
150
+ const parts = [];
151
+ let text2 = "";
152
+ const flush = () => {
153
+ if (text2.replace(/<[^>]*>|&nbsp;|\s/g, "")) {
154
+ parts.push({ ...node, id: parts.length ? createId() : node.id, attributes, content: text2.trim() });
155
+ }
156
+ text2 = "";
157
+ };
158
+ let last = 0;
159
+ for (const match of content.matchAll(MJML_IN_HTML)) {
160
+ text2 += content.slice(last, match.index);
161
+ last = (match.index ?? 0) + match[0].length;
162
+ const image = /^<mj-image\b/i.test(match[0]) && parseMjmlFragment(match[0]).find((child) => child.tag === "mj-image");
163
+ if (!image) continue;
164
+ flush();
165
+ parts.push(...cleanNode(image, keepImages));
166
+ }
167
+ text2 += content.slice(last);
168
+ flush();
169
+ return parts;
170
+ }
136
171
  function cleanNode(node, keepImages) {
137
172
  if (REMOVED_TAGS.has(node.tag)) return [];
138
173
  const children = node.children.flatMap((child) => cleanNode(child, keepImages));
@@ -156,6 +191,10 @@ function cleanNode(node, keepImages) {
156
191
  const wrapped = wrapLoose(sectioned, (child) => CONTENT_TAGS.has(child.tag), (nodes) => whiteSection([column(nodes)]));
157
192
  return [{ ...node, attributes: { "background-color": PALETTE.canvas, ...attributes, width: "600px" }, children: wrapped }];
158
193
  }
194
+ if (typeof node.content === "string" && /<\/?mj-/i.test(node.content)) {
195
+ if (node.tag === "mj-text") return liftImages(node, attributes, keepImages);
196
+ return [{ ...node, attributes, children, content: node.content.replace(MJML_IN_HTML, "") }];
197
+ }
159
198
  return [{ ...node, attributes, children }];
160
199
  }
161
200
  var placeholderOf = (key) => `[${key.replace(/[._]+/g, " ").trim()}]`;
@@ -173,6 +212,21 @@ function normalizeGeneratedMjml(mjml, { variables = [], keepImages = [] } = {})
173
212
  const known = /* @__PURE__ */ new Set(["unsubscribe", ...variables.map((variable) => variable.key)]);
174
213
  return replaceUnknownVariables(serializeMjml(root), known);
175
214
  }
215
+ function withoutIllegalAttributes(mjml, warnings) {
216
+ const illegal = /* @__PURE__ */ new Map();
217
+ for (const warning of warnings) {
218
+ const name = /Attribute ([\w-]+) is illegal/.exec(warning.message)?.[1];
219
+ const tag = warning.tagName || /\((mj-[\w-]+)\)/.exec(warning.message)?.[1];
220
+ if (name && tag) illegal.set(tag, /* @__PURE__ */ new Set([...illegal.get(tag) || [], name]));
221
+ }
222
+ if (!illegal.size) return null;
223
+ const strip = (node) => {
224
+ const names = illegal.get(node.tag);
225
+ const attributes = names ? Object.fromEntries(Object.entries(node.attributes).filter(([name]) => !names.has(name))) : node.attributes;
226
+ return { ...node, attributes, children: node.children.map(strip) };
227
+ };
228
+ return serializeMjml(strip(parseMjml(mjml)));
229
+ }
176
230
  function reviewDraft(draft, { kind = "marketing", compileWarnings = [] } = {}) {
177
231
  const blocking = validateMjml(draft.mjml).issues.map((issue) => issue.message);
178
232
  const advice = [];
@@ -241,7 +295,7 @@ Return one JSON object, nothing else: {"name": string, "subject": string, "prehe
241
295
 
242
296
  # Copy
243
297
  - Write the real email for the purpose and the details given: specific, useful, warm and concise. Never lorem ipsum, never meta comments.
244
- - 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.
298
+ - Personalize with the variables listed in the request, written exactly as {{key}}. A greeting gives the first name a natural default after a pipe, used when the name is unknown; the greeting and its default are in the email's language: Hello {{contact.firstname|there}}, in English, Bonjour {{contact.firstname|\xE0 vous}}, in French, \u0412\u0456\u0442\u0430\u0454\u043C\u043E, {{contact.firstname|\u0434\u0440\u0443\u0436\u0435}}! in Ukrainian. Use no other {{...}} variable.
245
299
  - 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.
246
300
  - Links read as what they do ("Reset my password", "See the collection"), never "click here".
247
301
  - 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>.
@@ -1330,7 +1384,10 @@ var fr = {
1330
1384
  "Free models, with a free account": "Mod\xE8les gratuits, avec un compte gratuit",
1331
1385
  "Free tier, with rate limits": "Offre gratuite, avec des limites de d\xE9bit",
1332
1386
  'Free "Experiment" plan': "Offre gratuite \xAB Experiment \xBB",
1333
- "Runs on your own machine": "Tourne sur votre propre machine"
1387
+ "Runs on your own machine": "Tourne sur votre propre machine",
1388
+ "AI provider": "Fournisseur d'IA",
1389
+ "Key saved": "Cl\xE9 enregistr\xE9e",
1390
+ "A key for {provider} is saved in this browser: Connect uses it, or paste another one.": "Une cl\xE9 {provider} est enregistr\xE9e dans ce navigateur : Connecter l'utilise, ou collez-en une autre."
1334
1391
  };
1335
1392
 
1336
1393
  // src/ai/i18n/index.js
@@ -1385,7 +1442,12 @@ function createEmailGenerator({ model, maxAttempts = 2, compile } = (
1385
1442
  const draft = parseDraft(answer);
1386
1443
  draft.mjml = normalizeGeneratedMjml(draft.mjml, { variables: request.variables, keepImages });
1387
1444
  draft.mjml = applyBrand(draft.mjml, request.brand);
1388
- const compileWarnings = compile ? (await compile(draft.mjml)).warnings || [] : [];
1445
+ let compileWarnings = compile ? (await compile(draft.mjml)).warnings || [] : [];
1446
+ const legal = withoutIllegalAttributes(draft.mjml, compileWarnings);
1447
+ if (legal && compile) {
1448
+ draft.mjml = legal;
1449
+ compileWarnings = (await compile(draft.mjml)).warnings || [];
1450
+ }
1389
1451
  const { blocking, advice } = reviewDraft(draft, { kind, compileWarnings });
1390
1452
  if (!blocking.length) {
1391
1453
  usable = { ...draft, warnings: advice, attempts: attempt };
@@ -1412,18 +1474,24 @@ function createEmailGenerator({ model, maxAttempts = 2, compile } = (
1412
1474
  }
1413
1475
 
1414
1476
  // src/ai/providers.js
1415
- var define = (provider) => Object.freeze({ format: "json_schema", tokens: "max_tokens", ...provider });
1477
+ var define = (provider) => Object.freeze({ format: "json_schema", tokens: "max_tokens", maxTokens: 16e3, ...provider });
1416
1478
  var AI_PROVIDERS = Object.freeze({
1417
1479
  openrouter: define({
1418
1480
  id: "openrouter",
1419
1481
  name: "OpenRouter",
1420
1482
  baseUrl: "https://openrouter.ai/api/v1",
1421
- // A router across the free models of the day.
1483
+ // A router across the free models of the day — a name that lasts, where
1484
+ // the free models themselves come and go (and an unknown one is a 400).
1422
1485
  model: "openrouter/free",
1423
1486
  keyUrl: "https://openrouter.ai/keys",
1424
1487
  keyHint: "sk-or-",
1425
1488
  free: "Free models, with a free account",
1426
- headers: { "X-Title": "Best Email Builder" }
1489
+ headers: { "X-Title": "Best Email Builder" },
1490
+ // Most free models reason before they answer: 5 000 to 12 000 tokens
1491
+ // before the email itself, which a limit of 16 000 cut short. Less
1492
+ // reasoning where the model lets it be chosen.
1493
+ maxTokens: 32e3,
1494
+ body: { reasoning: { effort: "low" } }
1427
1495
  }),
1428
1496
  openai: define({
1429
1497
  id: "openai",
@@ -1531,6 +1599,31 @@ function responseFormat(format, schema, task) {
1531
1599
  return void 0;
1532
1600
  }
1533
1601
  var formatRefused = (status, text2) => (status === 400 || status === 422) && /response_format|json_schema|json_object|structured|schema|grammar/i.test(text2);
1602
+ function errorMessage(text2) {
1603
+ try {
1604
+ const data = JSON.parse(text2);
1605
+ const error = Array.isArray(data) ? data[0]?.error : data?.error;
1606
+ return String(error?.metadata?.raw || error?.message || text2);
1607
+ } catch {
1608
+ return text2;
1609
+ }
1610
+ }
1611
+ function refusal(status, message) {
1612
+ if (status === 401) return "KEY_REFUSED";
1613
+ if (status === 400) return /api[ _-]?key not valid|API_KEY_INVALID|invalid[ _-]api[ _-]key/i.test(message) ? "KEY_REFUSED" : null;
1614
+ if (status !== 403) return null;
1615
+ if (/flagged|moderation/i.test(message)) return "REFUSED";
1616
+ if (/credit|limit exceeded|quota/i.test(message)) return "MODEL_CREDITS";
1617
+ if (/\bmodels?\b|harness|region|country|territory|not available|not allowed to (sample|access|use)/i.test(message)) return "MODEL_ERROR";
1618
+ return "KEY_REFUSED";
1619
+ }
1620
+ function failureCode(status, message) {
1621
+ const refused = refusal(status, message);
1622
+ if (refused) return refused;
1623
+ if (status === 402 || /credit balance|insufficient[ _](quota|credits|balance)|exceeded your current quota/i.test(message)) return "MODEL_CREDITS";
1624
+ if (status === 429 || status === 503 || status === 529) return "MODEL_BUSY";
1625
+ return "MODEL_ERROR";
1626
+ }
1534
1627
  function textOf(message) {
1535
1628
  const content = message?.content;
1536
1629
  if (typeof content === "string") return content;
@@ -1538,8 +1631,9 @@ function textOf(message) {
1538
1631
  return "";
1539
1632
  }
1540
1633
  function providerEmailModel(options = {}) {
1541
- const { apiKey, maxTokens = 16e3, temperature, fetch: fetchImpl } = options;
1634
+ const { apiKey, temperature, fetch: fetchImpl } = options;
1542
1635
  const provider = resolveProvider(options.provider ?? "openai-compatible");
1636
+ const maxTokens = options.maxTokens ?? provider.maxTokens;
1543
1637
  const model = options.model || provider.model;
1544
1638
  const baseUrl = String(options.baseUrl || provider.baseUrl).replace(/\/+$/, "");
1545
1639
  if (!baseUrl) throw new TypeError(`providerEmailModel: ${provider.name} needs a \`baseUrl\``);
@@ -1555,6 +1649,8 @@ function providerEmailModel(options = {}) {
1555
1649
  if (!key && !provider.keyless) throw new EmailGenerationError("KEY_MISSING", `${provider.name}: no key`);
1556
1650
  for (; ; ) {
1557
1651
  const body = {
1652
+ ...provider.body,
1653
+ ...options.body,
1558
1654
  model,
1559
1655
  messages: [{ role: "system", content: system + answerInstruction(schema) }, ...messages],
1560
1656
  [provider.tokens]: maxTokens,
@@ -1587,10 +1683,7 @@ function providerEmailModel(options = {}) {
1587
1683
  continue;
1588
1684
  }
1589
1685
  const detail = `${provider.name} ${response.status}: ${text2.slice(0, 300)}`;
1590
- if (response.status === 401 || response.status === 403) throw new EmailGenerationError("KEY_REFUSED", detail);
1591
- if (response.status === 402) throw new EmailGenerationError("MODEL_CREDITS", detail);
1592
- if (response.status === 429 || response.status === 503 || response.status === 529) throw new EmailGenerationError("MODEL_BUSY", detail);
1593
- throw new EmailGenerationError("MODEL_ERROR", detail);
1686
+ throw new EmailGenerationError(failureCode(response.status, errorMessage(text2)), detail);
1594
1687
  }
1595
1688
  let data;
1596
1689
  try {
@@ -1601,9 +1694,7 @@ function providerEmailModel(options = {}) {
1601
1694
  if (data?.error) {
1602
1695
  const code = Number(data.error.code) || 0;
1603
1696
  const detail = `${provider.name}: ${data.error.message || JSON.stringify(data.error).slice(0, 300)}`;
1604
- if (code === 401 || code === 403) throw new EmailGenerationError("KEY_REFUSED", detail);
1605
- if (code === 429 || code === 503) throw new EmailGenerationError("MODEL_BUSY", detail);
1606
- throw new EmailGenerationError("MODEL_ERROR", detail);
1697
+ throw new EmailGenerationError(failureCode(code, String(data.error.metadata?.raw || data.error.message || "")), detail);
1607
1698
  }
1608
1699
  const choice = data?.choices?.[0];
1609
1700
  if (choice?.message?.refusal) throw new EmailGenerationError("REFUSED", String(choice.message.refusal));
@@ -1631,16 +1722,32 @@ function claudeEmailModel({
1631
1722
  throw new Error("claudeEmailModel: pass an Anthropic client \u2014 `new Anthropic()` from @anthropic-ai/sdk");
1632
1723
  }
1633
1724
  return async ({ system, messages, schema, signal, task }) => {
1634
- const message = await client.messages.stream(
1635
- {
1636
- model,
1637
- max_tokens: maxTokens,
1638
- system,
1639
- messages,
1640
- output_config: { effort: task === "rewrite" || task === "subjects" ? rewriteEffort : effort, format: { type: "json_schema", schema } }
1641
- },
1642
- { signal }
1643
- ).finalMessage();
1725
+ let message;
1726
+ try {
1727
+ message = await client.messages.stream(
1728
+ {
1729
+ model,
1730
+ max_tokens: maxTokens,
1731
+ system,
1732
+ messages,
1733
+ output_config: { effort: task === "rewrite" || task === "subjects" ? rewriteEffort : effort, format: { type: "json_schema", schema } }
1734
+ },
1735
+ { signal }
1736
+ ).finalMessage();
1737
+ } catch (error) {
1738
+ const { status, name } = (
1739
+ /** @type {any} */
1740
+ error || {}
1741
+ );
1742
+ if (signal?.aborted || name === "AbortError" || name === "APIUserAbortError" || error instanceof EmailGenerationError) throw error;
1743
+ const text2 = String(
1744
+ /** @type {any} */
1745
+ error?.error?.error?.message || /** @type {any} */
1746
+ error?.message || error
1747
+ );
1748
+ if (typeof status !== "number") throw new EmailGenerationError("MODEL_ERROR", `Claude: ${text2.slice(0, 300)}`);
1749
+ throw new EmailGenerationError(failureCode(status, text2), `Claude ${status}: ${text2.slice(0, 300)}`);
1750
+ }
1644
1751
  if (message.stop_reason === "refusal") {
1645
1752
  throw new EmailGenerationError("REFUSED");
1646
1753
  }
@@ -1650,6 +1757,40 @@ function claudeEmailModel({
1650
1757
  return message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
1651
1758
  };
1652
1759
  }
1760
+ function claudeProvider(options) {
1761
+ const { client, ...rest } = options || /** @type {any} */
1762
+ {};
1763
+ if (typeof client !== "function") {
1764
+ throw new TypeError("claudeProvider: `client` is a function that makes the Anthropic client from a key");
1765
+ }
1766
+ return Object.freeze({
1767
+ id: "anthropic",
1768
+ name: "Claude",
1769
+ baseUrl: "https://api.anthropic.com",
1770
+ model: rest.model || DEFAULT_CLAUDE_MODEL,
1771
+ keyUrl: "https://console.anthropic.com/settings/keys",
1772
+ keyHint: "sk-ant-",
1773
+ free: null,
1774
+ format: (
1775
+ /** @type {const} */
1776
+ "json_schema"
1777
+ ),
1778
+ tokens: (
1779
+ /** @type {const} */
1780
+ "max_tokens"
1781
+ ),
1782
+ maxTokens: rest.maxTokens ?? 32e3,
1783
+ createModel: ({ apiKey }) => {
1784
+ let current = null;
1785
+ return async (request) => {
1786
+ const key = String(await apiKey() || "").trim();
1787
+ if (!key) throw new EmailGenerationError("KEY_MISSING", "Claude: no key");
1788
+ if (!current || current.key !== key) current = { key, model: claudeEmailModel({ ...rest, client: await client(key) }) };
1789
+ return current.model(request);
1790
+ };
1791
+ }
1792
+ });
1793
+ }
1653
1794
 
1654
1795
  export {
1655
1796
  GENERATION_ERRORS,
@@ -1680,8 +1821,10 @@ export {
1680
1821
  createEmailGenerator,
1681
1822
  AI_PROVIDERS,
1682
1823
  resolveProvider,
1824
+ failureCode,
1683
1825
  providerEmailModel,
1684
1826
  DEFAULT_CLAUDE_MODEL,
1685
- claudeEmailModel
1827
+ claudeEmailModel,
1828
+ claudeProvider
1686
1829
  };
1687
- //# sourceMappingURL=chunk-MPX5CWTZ.js.map
1830
+ //# sourceMappingURL=chunk-NZIQF7IW.js.map