best-email-builder 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +290 -20
  3. package/dist/ai-server.js +68 -0
  4. package/dist/ai-server.js.map +1 -0
  5. package/dist/ai.js +852 -0
  6. package/dist/ai.js.map +1 -0
  7. package/dist/builder.js +16 -8
  8. package/dist/chunk-2RZHGAH5.js +64 -0
  9. package/dist/chunk-2RZHGAH5.js.map +1 -0
  10. package/dist/chunk-3LKF4QBL.js +595 -0
  11. package/dist/chunk-3LKF4QBL.js.map +1 -0
  12. package/dist/chunk-5SASKCUC.js +251 -0
  13. package/dist/chunk-5SASKCUC.js.map +1 -0
  14. package/dist/{chunk-JOC752I3.js → chunk-6D3SLT5W.js} +27 -29
  15. package/dist/chunk-6D3SLT5W.js.map +1 -0
  16. package/dist/chunk-7U6BAKM7.js +119 -0
  17. package/dist/chunk-7U6BAKM7.js.map +1 -0
  18. package/dist/chunk-DFNHICTF.js +1359 -0
  19. package/dist/chunk-DFNHICTF.js.map +1 -0
  20. package/dist/chunk-ECHGJZ2W.js +327 -0
  21. package/dist/chunk-ECHGJZ2W.js.map +1 -0
  22. package/dist/chunk-KXJJSVN5.js +98 -0
  23. package/dist/chunk-KXJJSVN5.js.map +1 -0
  24. package/dist/{chunk-ZSUHOCCE.js → chunk-LXR4QTGP.js} +123 -579
  25. package/dist/chunk-LXR4QTGP.js.map +1 -0
  26. package/dist/{chunk-BVOVAEEJ.js → chunk-LYMJ2TBX.js} +2 -2
  27. package/dist/chunk-MPX5CWTZ.js +1687 -0
  28. package/dist/chunk-MPX5CWTZ.js.map +1 -0
  29. package/dist/chunk-PB7YZ3WP.js +5635 -0
  30. package/dist/chunk-PB7YZ3WP.js.map +1 -0
  31. package/dist/chunk-UFKCLU2D.js +255 -0
  32. package/dist/chunk-UFKCLU2D.js.map +1 -0
  33. package/dist/index.js +51 -20
  34. package/dist/index.js.map +1 -1
  35. package/dist/library.js +12 -7
  36. package/dist/server.js +513 -214
  37. package/dist/server.js.map +1 -1
  38. package/dist/styles.css +1 -1
  39. package/dist/types/ai/ConnectAi.d.ts +12 -0
  40. package/dist/types/ai/GenerateButton.d.ts +4 -0
  41. package/dist/types/ai/RewriteText.d.ts +5 -0
  42. package/dist/types/ai/SubjectIdeas.d.ts +25 -0
  43. package/dist/types/ai/chip.d.ts +1 -0
  44. package/dist/types/ai/claude.d.ts +21 -0
  45. package/dist/types/ai/createAi.d.ts +66 -0
  46. package/dist/types/ai/draft.d.ts +163 -0
  47. package/dist/types/ai/generator.d.ts +123 -0
  48. package/dist/types/ai/i18n/fr.d.ts +2 -0
  49. package/dist/types/ai/i18n/index.d.ts +2 -0
  50. package/dist/types/ai/index.d.ts +9 -0
  51. package/dist/types/ai/keys.d.ts +46 -0
  52. package/dist/types/ai/prompt.d.ts +76 -0
  53. package/dist/types/ai/providers.d.ts +67 -0
  54. package/dist/types/ai/purposes.d.ts +48 -0
  55. package/dist/types/ai/rewrite.d.ts +83 -0
  56. package/dist/types/ai/server.d.ts +9 -0
  57. package/dist/types/ai/subjects.d.ts +97 -0
  58. package/dist/types/blocks/shared.d.ts +20 -0
  59. package/dist/types/core/EmailEditor.d.ts +72 -19
  60. package/dist/types/core/constants.d.ts +1 -1
  61. package/dist/types/core/mjmlDefaults.d.ts +13 -0
  62. package/dist/types/core/scrollbars.d.ts +1 -1
  63. package/dist/types/defaultEditor.d.ts +4 -1
  64. package/dist/types/engine/NativeAdapter.d.ts +28 -0
  65. package/dist/types/engine/canvas/CanvasView.d.ts +88 -0
  66. package/dist/types/engine/canvas/canvasStyles.d.ts +3 -0
  67. package/dist/types/engine/canvas/icons.d.ts +14 -0
  68. package/dist/types/engine/canvas/morph.d.ts +21 -0
  69. package/dist/types/engine/canvasMjml.d.ts +30 -0
  70. package/dist/types/engine/mjml.d.ts +31 -0
  71. package/dist/types/engine/model.d.ts +130 -0
  72. package/dist/types/engine/placement.d.ts +69 -0
  73. package/dist/types/engine/richText.d.ts +15 -0
  74. package/dist/types/engine/unwrapModule.d.ts +29 -0
  75. package/dist/types/features/brand/BrandButton.d.ts +4 -0
  76. package/dist/types/features/brand/brand.d.ts +108 -0
  77. package/dist/types/features/brand/extract.d.ts +61 -0
  78. package/dist/types/features/brand/index.d.ts +2 -0
  79. package/dist/types/features/brand/messages.d.ts +13 -0
  80. package/dist/types/features/brand/useBrand.d.ts +22 -0
  81. package/dist/types/features/preview/PreviewButton.d.ts +1 -1
  82. package/dist/types/features/preview/darkMode.d.ts +65 -0
  83. package/dist/types/features/variables/VariablePicker.d.ts +3 -2
  84. package/dist/types/i18n/index.d.ts +8 -0
  85. package/dist/types/index.d.ts +5 -2
  86. package/dist/types/server/brand.d.ts +32 -0
  87. package/dist/types/server/brandImport.d.ts +93 -0
  88. package/dist/types/server/compileMjml.d.ts +2 -0
  89. package/dist/types/server/handler.d.ts +26 -0
  90. package/dist/types/server/index.d.ts +5 -1
  91. package/dist/types/storage/client.d.ts +57 -3
  92. package/dist/types/ui/EmailBuilder.d.ts +5 -1
  93. package/dist/types/ui/EmailStudio.d.ts +6 -1
  94. package/dist/types/ui/PropertiesPanel.d.ts +2 -1
  95. package/dist/types/ui/fields.d.ts +2 -1
  96. package/dist/types/ui/primitives/button.d.ts +1 -1
  97. package/dist/types/ui/primitives/color-picker.d.ts +2 -1
  98. package/dist/types/ui/primitives/searchShortcut.d.ts +16 -0
  99. package/dist/types/ui/useLayoutWidth.d.ts +1 -1
  100. package/package.json +27 -7
  101. package/dist/chunk-3M36VVXS.js +0 -665
  102. package/dist/chunk-3M36VVXS.js.map +0 -1
  103. package/dist/chunk-57CTQNTK.js +0 -56
  104. package/dist/chunk-57CTQNTK.js.map +0 -1
  105. package/dist/chunk-ILRAQTHS.js +0 -3677
  106. package/dist/chunk-ILRAQTHS.js.map +0 -1
  107. package/dist/chunk-JOC752I3.js.map +0 -1
  108. package/dist/chunk-ZSUHOCCE.js.map +0 -1
  109. package/dist/types/adapters/grapesjs/GrapesJsAdapter.d.ts +0 -33
  110. package/dist/types/adapters/grapesjs/engineConfig.d.ts +0 -148
  111. package/dist/types/adapters/grapesjs/unwrapModule.d.ts +0 -18
  112. /package/dist/{chunk-BVOVAEEJ.js.map → chunk-LYMJ2TBX.js.map} +0 -0
@@ -0,0 +1,1687 @@
1
+ import {
2
+ LONGHAND_SPACING,
3
+ SIDES,
4
+ parseSpacing
5
+ } from "./chunk-2RZHGAH5.js";
6
+ import {
7
+ isSafeUrl,
8
+ validateMjml
9
+ } from "./chunk-5SASKCUC.js";
10
+ import {
11
+ DEFAULT_VARIABLES,
12
+ FONT_OPTIONS,
13
+ PALETTE,
14
+ applyBrand,
15
+ cleanBrand,
16
+ createNode,
17
+ parseMjml,
18
+ serializeMjml
19
+ } from "./chunk-DFNHICTF.js";
20
+
21
+ // src/ai/draft.js
22
+ var GENERATION_ERRORS = {
23
+ NOTHING_TO_GENERATE: { status: 400, message: "Pick what the email is for, or describe it." },
24
+ REFUSED: { status: 422, message: "The AI declined to write this email. Try other details." },
25
+ TRUNCATED: { status: 502, message: "The email was too long to finish. Ask for a shorter one." },
26
+ INVALID_ANSWER: { status: 502, message: "The AI answer could not be read. Try again." },
27
+ INVALID_OUTPUT: { status: 502, message: "The generated email could not be used. Try again." },
28
+ NOTHING_TO_REWRITE: { status: 400, message: "There is no text to rewrite." },
29
+ REWRITE_REFUSED: { status: 422, message: "The AI declined to rewrite this text." },
30
+ REWRITE_UNUSABLE: { status: 502, message: "The rewritten text lost a link or a variable, so nothing was changed. Try again." },
31
+ NOTHING_TO_SUGGEST: { status: 400, message: "Write the email or a subject first: the ideas are drawn from it." },
32
+ SUBJECTS_REFUSED: { status: 422, message: "The AI declined to suggest subjects for this email." },
33
+ SUBJECTS_UNUSABLE: { status: 502, message: "No usable subject came back. Try again." },
34
+ // The provider's side (providers.js). Never 401 to the browser: the key is the AI's, not the user's session.
35
+ KEY_MISSING: { status: 502, message: "Connect an AI provider first: it needs a key." },
36
+ KEY_REFUSED: { status: 502, message: "The AI provider refused the key. Check it, or use another one." },
37
+ MODEL_CREDITS: { status: 502, message: "The AI provider account has no credits left." },
38
+ MODEL_BUSY: { status: 503, message: "The AI is busy right now. Try again in a minute." },
39
+ MODEL_ERROR: { status: 502, message: "The AI provider could not answer. Try again." }
40
+ };
41
+ var EmailGenerationError = class extends Error {
42
+ /**
43
+ * @param {keyof typeof GENERATION_ERRORS} code
44
+ * @param {string} [detail] what went wrong, for logs and for the model
45
+ */
46
+ constructor(code, detail = "") {
47
+ const known = GENERATION_ERRORS[code] || GENERATION_ERRORS.INVALID_OUTPUT;
48
+ super(known.message);
49
+ this.name = "EmailGenerationError";
50
+ this.code = code;
51
+ this.status = known.status;
52
+ this.detail = detail || known.message;
53
+ }
54
+ };
55
+ var LIMITS = { name: 80, subject: 150, preheader: 200 };
56
+ function plainLine(value, max) {
57
+ return String(value ?? "").replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim().slice(0, max);
58
+ }
59
+ function unescapedTwice(markup) {
60
+ if (!/=\\"/.test(markup) || /=\s*"/.test(markup.replace(/=\\"/g, ""))) return markup;
61
+ try {
62
+ const decoded = JSON.parse(`"${markup}"`);
63
+ if (typeof decoded === "string") return decoded;
64
+ } catch {
65
+ }
66
+ return markup.replace(/\\"/g, '"').replace(/\\n/g, "\n");
67
+ }
68
+ function parseDraft(text2) {
69
+ const source = String(text2 ?? "").trim();
70
+ const unfenced = source.replace(/^```[a-z]*\s*\n?/i, "").replace(/\n?```\s*$/, "").trim();
71
+ let data = null;
72
+ const start = unfenced.indexOf("{");
73
+ const end = unfenced.lastIndexOf("}");
74
+ if (start !== -1 && end > start) {
75
+ try {
76
+ data = JSON.parse(unfenced.slice(start, end + 1));
77
+ } catch {
78
+ data = null;
79
+ }
80
+ }
81
+ if (!data && /^<mjml[\s>]/i.test(unfenced)) data = { mjml: unfenced };
82
+ if (!data || typeof data.mjml !== "string" || !data.mjml.trim()) {
83
+ throw new EmailGenerationError("INVALID_ANSWER", 'The answer is not the expected JSON object with an "mjml" field.');
84
+ }
85
+ return {
86
+ name: plainLine(data.name, LIMITS.name),
87
+ subject: plainLine(data.subject, LIMITS.subject),
88
+ preheader: plainLine(data.preheader, LIMITS.preheader),
89
+ mjml: unescapedTwice(data.mjml.trim())
90
+ };
91
+ }
92
+ var CONTENT_TAGS = /* @__PURE__ */ new Set(["mj-text", "mj-image", "mj-button", "mj-divider", "mj-spacer", "mj-social", "mj-navbar", "mj-table"]);
93
+ var REMOVED_TAGS = /* @__PURE__ */ new Set(["mj-head", "mj-raw", "mj-style", "mj-font", "mj-include", "mj-attributes"]);
94
+ var REMOVED_ATTRIBUTES = ["css-class", "mj-class"];
95
+ var PIXEL_ATTRIBUTES = /* @__PURE__ */ new Set(["font-size", "border-radius", "icon-size", "height", "width", "letter-spacing"]);
96
+ var HERO_KEPT = ["background-color", "padding-top", "padding-right", "padding-bottom", "padding-left", "padding", "border-radius"];
97
+ var whiteSection = (children) => createNode("mj-section", { attributes: { "background-color": PALETTE.surface }, children });
98
+ var column = (children) => createNode("mj-column", { children });
99
+ function cleanAttributes(node, keepImages) {
100
+ const attributes = { ...node.attributes };
101
+ for (const name of REMOVED_ATTRIBUTES) delete attributes[name];
102
+ for (const key of LONGHAND_SPACING) {
103
+ if (attributes[key] === void 0) continue;
104
+ const sides = parseSpacing(attributes[key]);
105
+ SIDES.forEach((side, i) => {
106
+ const longhand = `${key}-${side}`;
107
+ if (attributes[longhand] === void 0) attributes[longhand] = sides[i];
108
+ });
109
+ delete attributes[key];
110
+ }
111
+ for (const [name, value] of Object.entries(attributes)) {
112
+ const unitless = /^-?\d+(\.\d+)?$/.test(value.trim());
113
+ if (unitless && (PIXEL_ATTRIBUTES.has(name) || /^padding-/.test(name))) attributes[name] = `${value.trim()}px`;
114
+ }
115
+ if (node.tag === "mj-image" && attributes.src !== void 0 && !keepImages.has(attributes.src.trim())) attributes.src = "";
116
+ delete attributes["background-url"];
117
+ return attributes;
118
+ }
119
+ function wrapLoose(children, isLoose, wrap) {
120
+ const out = [];
121
+ let run = [];
122
+ const flush = () => {
123
+ if (run.length) out.push(wrap(run));
124
+ run = [];
125
+ };
126
+ for (const child of children) {
127
+ if (isLoose(child)) run.push(child);
128
+ else {
129
+ flush();
130
+ out.push(child);
131
+ }
132
+ }
133
+ flush();
134
+ return out;
135
+ }
136
+ function cleanNode(node, keepImages) {
137
+ if (REMOVED_TAGS.has(node.tag)) return [];
138
+ const children = node.children.flatMap((child) => cleanNode(child, keepImages));
139
+ const attributes = cleanAttributes(node, keepImages);
140
+ if (node.tag === "mj-wrapper") {
141
+ return children.map(
142
+ (child) => child.attributes["background-color"] || !attributes["background-color"] ? child : { ...child, attributes: { ...child.attributes, "background-color": attributes["background-color"] } }
143
+ );
144
+ }
145
+ if (node.tag === "mj-group") return children;
146
+ if (node.tag === "mj-hero") {
147
+ const kept = Object.fromEntries(Object.entries(attributes).filter(([name]) => HERO_KEPT.includes(name)));
148
+ const columns = children.some((child) => child.tag === "mj-column") ? children : [column(children)];
149
+ return [createNode("mj-section", { id: node.id, attributes: { "background-color": PALETTE.navy, ...kept }, children: columns })];
150
+ }
151
+ if (node.tag === "mj-section") {
152
+ return [{ ...node, attributes, children: wrapLoose(children, (child) => child.tag !== "mj-column", column) }];
153
+ }
154
+ if (node.tag === "mj-body") {
155
+ const sectioned = wrapLoose(children, (child) => child.tag === "mj-column", (nodes) => whiteSection(nodes));
156
+ const wrapped = wrapLoose(sectioned, (child) => CONTENT_TAGS.has(child.tag), (nodes) => whiteSection([column(nodes)]));
157
+ return [{ ...node, attributes: { "background-color": PALETTE.canvas, ...attributes, width: "600px" }, children: wrapped }];
158
+ }
159
+ return [{ ...node, attributes, children }];
160
+ }
161
+ var placeholderOf = (key) => `[${key.replace(/[._]+/g, " ").trim()}]`;
162
+ function replaceUnknownVariables(mjml, known) {
163
+ const unknown = (key) => !known.has(key);
164
+ const inUrls = mjml.replace(
165
+ /\s(href|src)="([^"]*)"/g,
166
+ (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
167
+ );
168
+ return inUrls.replace(/\{\{\s*([A-Za-z][\w.]*)\s*(?:\|[^{}]*)?\}\}/g, (whole, key) => unknown(key) ? placeholderOf(key) : whole);
169
+ }
170
+ function normalizeGeneratedMjml(mjml, { variables = [], keepImages = [] } = {}) {
171
+ const tree = parseMjml(String(mjml || ""));
172
+ const [root] = cleanNode(tree, new Set(keepImages.filter(Boolean)));
173
+ const known = /* @__PURE__ */ new Set(["unsubscribe", ...variables.map((variable) => variable.key)]);
174
+ return replaceUnknownVariables(serializeMjml(root), known);
175
+ }
176
+ function reviewDraft(draft, { kind = "marketing", compileWarnings = [] } = {}) {
177
+ const blocking = validateMjml(draft.mjml).issues.map((issue) => issue.message);
178
+ const advice = [];
179
+ if (!/<mj-section[\s>]/.test(draft.mjml)) advice.push("The email has no <mj-section>: build it from sections and columns.");
180
+ const unsubscribe = /\{\{\s*unsubscribe\s*\}\}/.test(draft.mjml);
181
+ if (kind !== "security" && !unsubscribe) {
182
+ advice.push('The footer has no link with href="{{ unsubscribe }}".');
183
+ }
184
+ if (kind === "security" && unsubscribe) advice.push("A security email must not have an unsubscribe link.");
185
+ if (!draft.subject) advice.push("The subject is empty.");
186
+ if (!draft.preheader) advice.push("The preheader is empty.");
187
+ for (const warning of compileWarnings.slice(0, 8)) advice.push(`MJML: ${warning.message}`);
188
+ return { blocking, advice };
189
+ }
190
+
191
+ // src/ai/prompt.js
192
+ var EMAIL_DRAFT_SCHEMA = {
193
+ type: "object",
194
+ properties: {
195
+ name: { type: "string", description: "Short internal name of the template" },
196
+ subject: { type: "string", description: "Subject line" },
197
+ preheader: { type: "string", description: "Preview text shown after the subject in the inbox" },
198
+ mjml: { type: "string", description: "The complete MJML document" }
199
+ },
200
+ required: ["name", "subject", "preheader", "mjml"],
201
+ additionalProperties: false
202
+ };
203
+ var FONTS = FONT_OPTIONS.map((option) => `"${option.value}"`).join(", ");
204
+ 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.
205
+
206
+ # Answer
207
+ Return one JSON object, nothing else: {"name": string, "subject": string, "preheader": string, "mjml": string}.
208
+ - name: a short internal template name (at most 50 characters), in the email's language.
209
+ - 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.
210
+ - preheader: 40 to 110 characters that complete the subject (never repeat it).
211
+ - mjml: the full MJML document.
212
+
213
+ # MJML the builder can edit
214
+ - 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.
215
+ - The body is a vertical stack of <mj-section>. Each section holds one to four <mj-column>. Content elements live only inside a column.
216
+ - Allowed elements, and only these: mj-section, mj-column, mj-text, mj-image, mj-button, mj-divider, mj-spacer, mj-social, mj-social-element.
217
+ - 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>.
218
+ - 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.
219
+ - Inline style attributes only on <a>, <span>, <p>, <li>, and only for color, font-weight, font-style, text-decoration and margin.
220
+
221
+ # Attributes
222
+ - 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").
223
+ - 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".
224
+ - Fonts: web-safe only, one family for the whole email (at most two: one for headings, one for text): ${FONTS}. No web fonts.
225
+ - 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.
226
+ - 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.
227
+ - Column widths in percent only when unequal (for example width="40%" and width="60%"), always adding up to 100%.
228
+ - 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.
229
+ - 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).
230
+ - Dividers: border-color in a light gray, border-width="1px".
231
+ - 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.
232
+
233
+ # Design
234
+ - 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.
235
+ - 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.
236
+ - 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.
237
+ - 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.
238
+ - A hero is a section with a background color, a large heading, one sentence and usually the main button, centered.
239
+ - 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.
240
+ - Mobile first: columns stack on phones, so each column must make sense on its own. Short paragraphs of one to three sentences.
241
+
242
+ # Copy
243
+ - 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.
245
+ - 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
+ - Links read as what they do ("Reset my password", "See the collection"), never "click here".
247
+ - 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>.
248
+ - Keep the MJML under 30 KB.`;
249
+ var FOOTER_RULES = {
250
+ marketing: 'This is a marketing email: the footer MUST contain the unsubscribe link (href="{{ unsubscribe }}").',
251
+ transactional: 'This is a transactional email: the footer contains a discreet "Manage email preferences" link with href="{{ unsubscribe }}".',
252
+ security: "This is a security email: NO unsubscribe or preferences link. The footer says in one line why the recipient got this email."
253
+ };
254
+ function languageName(code) {
255
+ const value = String(code || "en");
256
+ try {
257
+ return new Intl.DisplayNames(["en"], { type: "language" }).of(value) || value;
258
+ } catch {
259
+ return value;
260
+ }
261
+ }
262
+ function variableLines(variables) {
263
+ return variables.filter((variable) => variable.key !== "unsubscribe").map((variable) => `- {{${variable.key}}}${variable.label ? `: ${variable.label}` : ""}`).join("\n");
264
+ }
265
+ function buildUserPrompt({ purpose: purpose2 = null, details = "", language = "en", sender = {}, brand = {}, variables = [] }) {
266
+ const lines = [];
267
+ if (purpose2) {
268
+ lines.push(`Write a "${purpose2.label}" email.`, "", `Brief: ${purpose2.brief}`);
269
+ } else {
270
+ lines.push("Write the email described below.");
271
+ }
272
+ const extra = String(details || "").trim();
273
+ if (extra) lines.push("", "Details from the user (they take precedence over the brief):", extra);
274
+ lines.push(
275
+ "",
276
+ `Language: write everything in ${languageName(language)}, unless the details ask for another language.`,
277
+ "",
278
+ "Sender:",
279
+ `- name: ${sender.name?.trim() || "[Company name]"}`,
280
+ `- postal address: ${sender.address?.trim() || "[Company address]"}`,
281
+ 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."
282
+ );
283
+ const look = [
284
+ brand.primary && `- main color (buttons, banners, links): ${brand.primary}`,
285
+ brand.heading && `- headings color: ${brand.heading}`,
286
+ brand.font && `- font: font-family="${brand.font}" on every text and button`
287
+ ].filter(Boolean);
288
+ if (look.length) lines.push("", "Brand (use exactly these):", ...look);
289
+ const vars = variableLines(variables);
290
+ if (vars) lines.push("", "Variables you may use:", vars);
291
+ lines.push("", FOOTER_RULES[purpose2?.kind || "marketing"]);
292
+ return lines.join("\n");
293
+ }
294
+ function buildRepairPrompt(problems) {
295
+ return [
296
+ "The email cannot be opened in the builder as it is. Fix these problems and return the complete corrected JSON object:",
297
+ ...problems.map((problem) => `- ${problem}`)
298
+ ].join("\n");
299
+ }
300
+
301
+ // src/ai/purposes.js
302
+ var PURPOSE_CATEGORIES = [
303
+ { id: "promotions", label: "Sales and promotions" },
304
+ { id: "holidays", label: "Holidays and occasions" },
305
+ { id: "events", label: "Events and appointments" },
306
+ { id: "news", label: "News and content" },
307
+ { id: "account", label: "Account and security" },
308
+ { id: "orders", label: "Orders and payments" },
309
+ { id: "relationship", label: "Customer relationship" },
310
+ { id: "organization", label: "Business and organization" }
311
+ ];
312
+ var purpose = (id, label, category, kind, brief) => ({ id, label, category, kind, brief });
313
+ var EMAIL_PURPOSES = [
314
+ // Sales and promotions
315
+ purpose(
316
+ "sale",
317
+ "Store sale",
318
+ "promotions",
319
+ "marketing",
320
+ '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.'
321
+ ),
322
+ purpose(
323
+ "flash-sale",
324
+ "Flash sale",
325
+ "promotions",
326
+ "marketing",
327
+ "A sale that lasts hours, not days. Urgency first: the deadline in the hero, a large discount, one button, very little text."
328
+ ),
329
+ purpose(
330
+ "discount-code",
331
+ "Discount code",
332
+ "promotions",
333
+ "marketing",
334
+ "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."
335
+ ),
336
+ purpose(
337
+ "new-product",
338
+ "New product",
339
+ "promotions",
340
+ "marketing",
341
+ '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.'
342
+ ),
343
+ purpose(
344
+ "back-in-stock",
345
+ "Back in stock",
346
+ "promotions",
347
+ "marketing",
348
+ 'A product the recipient wanted is available again. The product (image, name, price), "limited stock" urgency without exaggeration, and a "Buy now" button.'
349
+ ),
350
+ purpose(
351
+ "price-drop",
352
+ "Price drop",
353
+ "promotions",
354
+ "marketing",
355
+ "A product the recipient looked at is now cheaper. Old and new price side by side, the product image, and a button."
356
+ ),
357
+ purpose(
358
+ "abandoned-cart",
359
+ "Abandoned cart",
360
+ "promotions",
361
+ "marketing",
362
+ '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).'
363
+ ),
364
+ purpose(
365
+ "black-friday",
366
+ "Black Friday",
367
+ "promotions",
368
+ "marketing",
369
+ "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."
370
+ ),
371
+ purpose(
372
+ "loyalty-reward",
373
+ "Loyalty reward",
374
+ "promotions",
375
+ "marketing",
376
+ "Thank a loyal customer with a reward: their status or points balance, the reward, how to use it, and a button."
377
+ ),
378
+ purpose(
379
+ "refer-a-friend",
380
+ "Refer a friend",
381
+ "promotions",
382
+ "marketing",
383
+ "Invite the customer to refer friends. What the friend gets, what the customer gets, three short steps, and a button to share their link."
384
+ ),
385
+ // Holidays and occasions
386
+ purpose(
387
+ "christmas",
388
+ "Christmas",
389
+ "holidays",
390
+ "marketing",
391
+ "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."
392
+ ),
393
+ purpose(
394
+ "new-year",
395
+ "New Year",
396
+ "holidays",
397
+ "marketing",
398
+ "New Year wishes. A celebratory hero, thanks for the past year, one or two highlights or promises for the year ahead, and warm wishes."
399
+ ),
400
+ purpose(
401
+ "season-greetings",
402
+ "Season's greetings",
403
+ "holidays",
404
+ "marketing",
405
+ "End-of-year greetings that suit every culture and belief (no religious reference): thanks, warm wishes, and the holiday closing dates."
406
+ ),
407
+ purpose(
408
+ "valentines-day",
409
+ "Valentine's Day",
410
+ "holidays",
411
+ "marketing",
412
+ "Valentine's Day: a romantic tone (soft pink or red), gift ideas in columns, and a delivery deadline."
413
+ ),
414
+ purpose(
415
+ "easter",
416
+ "Easter",
417
+ "holidays",
418
+ "marketing",
419
+ "Easter: a spring palette, greetings, and an optional seasonal offer."
420
+ ),
421
+ purpose(
422
+ "mothers-day",
423
+ "Mother's Day",
424
+ "holidays",
425
+ "marketing",
426
+ "Mother's Day: gift ideas in columns, the order deadline for delivery in time, and a gentle tone."
427
+ ),
428
+ purpose(
429
+ "fathers-day",
430
+ "Father's Day",
431
+ "holidays",
432
+ "marketing",
433
+ "Father's Day: gift ideas in columns, the order deadline for delivery in time, and a warm tone."
434
+ ),
435
+ purpose(
436
+ "halloween",
437
+ "Halloween",
438
+ "holidays",
439
+ "marketing",
440
+ "Halloween: a playful, spooky tone (orange and near-black, still readable), a themed offer or event."
441
+ ),
442
+ purpose(
443
+ "back-to-school",
444
+ "Back to school",
445
+ "holidays",
446
+ "marketing",
447
+ "Back to school: essentials or a checklist in columns, an offer, and a cheerful tone."
448
+ ),
449
+ purpose(
450
+ "summer",
451
+ "Summer holidays",
452
+ "holidays",
453
+ "marketing",
454
+ "Summer: a bright palette, summer picks or plans, and the summer opening hours if relevant."
455
+ ),
456
+ purpose(
457
+ "birthday",
458
+ "Birthday wishes",
459
+ "holidays",
460
+ "marketing",
461
+ "Happy birthday to the recipient by first name: a festive hero, a personal message, and a birthday gift or code with its validity."
462
+ ),
463
+ purpose(
464
+ "anniversary",
465
+ "Customer anniversary",
466
+ "holidays",
467
+ "marketing",
468
+ "One (or more) years since the recipient became a customer: thanks, a short look back, and a small gift."
469
+ ),
470
+ purpose(
471
+ "womens-day",
472
+ "Women's Day",
473
+ "holidays",
474
+ "marketing",
475
+ "International Women's Day (March 8): respectful greetings, an optional offer or a story about women of the team, no clich\xE9s."
476
+ ),
477
+ purpose(
478
+ "thanksgiving",
479
+ "Thanksgiving",
480
+ "holidays",
481
+ "marketing",
482
+ "Thanksgiving: gratitude to customers, a warm autumn palette, and an optional offer."
483
+ ),
484
+ purpose(
485
+ "lunar-new-year",
486
+ "Lunar New Year",
487
+ "holidays",
488
+ "marketing",
489
+ "Lunar New Year: wishes for the new year (red and gold accents), the animal of the year if relevant, and an optional offer."
490
+ ),
491
+ purpose(
492
+ "ramadan-eid",
493
+ "Ramadan and Eid",
494
+ "holidays",
495
+ "marketing",
496
+ "Ramadan Kareem or Eid Mubarak greetings: respectful and warm, adapted opening hours, an optional offer."
497
+ ),
498
+ purpose(
499
+ "diwali",
500
+ "Diwali",
501
+ "holidays",
502
+ "marketing",
503
+ "Diwali, the festival of lights: warm wishes, a light-filled palette (gold, deep orange), an optional offer."
504
+ ),
505
+ // Events and appointments
506
+ purpose(
507
+ "meeting-reminder",
508
+ "Meeting reminder",
509
+ "events",
510
+ "transactional",
511
+ "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."
512
+ ),
513
+ purpose(
514
+ "appointment-confirmation",
515
+ "Appointment confirmation",
516
+ "events",
517
+ "transactional",
518
+ "Confirm a booked appointment (doctor, salon, garage, consultant): service, date and time, address with directions, how to cancel or reschedule, and a contact."
519
+ ),
520
+ purpose(
521
+ "reservation-confirmation",
522
+ "Booking confirmation",
523
+ "events",
524
+ "transactional",
525
+ "Confirm a booking (restaurant table, hotel room, class): booking details in a clear summary box, arrival information, and how to change it."
526
+ ),
527
+ purpose(
528
+ "event-invitation",
529
+ "Event invitation",
530
+ "events",
531
+ "marketing",
532
+ '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.'
533
+ ),
534
+ purpose(
535
+ "webinar-invitation",
536
+ "Webinar invitation",
537
+ "events",
538
+ "marketing",
539
+ '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.'
540
+ ),
541
+ purpose(
542
+ "save-the-date",
543
+ "Save the date",
544
+ "events",
545
+ "marketing",
546
+ '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.'
547
+ ),
548
+ purpose(
549
+ "registration-confirmed",
550
+ "Registration confirmed",
551
+ "events",
552
+ "transactional",
553
+ '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.'
554
+ ),
555
+ purpose(
556
+ "event-change",
557
+ "Cancellation or new date",
558
+ "events",
559
+ "transactional",
560
+ "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."
561
+ ),
562
+ purpose(
563
+ "event-follow-up",
564
+ "Thanks for attending",
565
+ "events",
566
+ "marketing",
567
+ "Thank attendees after an event: a recap, links to the replay or slides, a short survey, and the next event."
568
+ ),
569
+ // News and content
570
+ purpose(
571
+ "newsletter",
572
+ "Newsletter",
573
+ "news",
574
+ "marketing",
575
+ '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.'
576
+ ),
577
+ purpose(
578
+ "new-article",
579
+ "New article",
580
+ "news",
581
+ "marketing",
582
+ 'Share one new blog article or video: its image, title, a two-sentence teaser, and a "Read the article" button.'
583
+ ),
584
+ purpose(
585
+ "product-update",
586
+ "What's new",
587
+ "news",
588
+ "marketing",
589
+ "A product update or release notes: the headline feature with an image, 2 or 3 smaller improvements, and a button to try it."
590
+ ),
591
+ purpose(
592
+ "announcement",
593
+ "Announcement",
594
+ "news",
595
+ "marketing",
596
+ "An important announcement (new name, new address, partnership, milestone): what changes, why, what it means for the recipient, and a button."
597
+ ),
598
+ purpose(
599
+ "tips",
600
+ "Tips and how-to",
601
+ "news",
602
+ "marketing",
603
+ "Practical tips: 3 to 5 numbered tips, each with a short title and one or two sentences, and a button to learn more."
604
+ ),
605
+ // Account and security
606
+ purpose(
607
+ "welcome",
608
+ "Welcome",
609
+ "account",
610
+ "transactional",
611
+ "Welcome a new user or subscriber: a warm hero, what they can expect, 3 first steps, and one button to get started."
612
+ ),
613
+ purpose(
614
+ "verify-email",
615
+ "Email verification",
616
+ "account",
617
+ "security",
618
+ '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".'
619
+ ),
620
+ purpose(
621
+ "password-reset",
622
+ "Password reset",
623
+ "account",
624
+ "security",
625
+ '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.'
626
+ ),
627
+ purpose(
628
+ "login-code",
629
+ "Login code",
630
+ "account",
631
+ "security",
632
+ "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."
633
+ ),
634
+ purpose(
635
+ "security-alert",
636
+ "Security alert",
637
+ "account",
638
+ "security",
639
+ '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.'
640
+ ),
641
+ purpose(
642
+ "onboarding",
643
+ "Getting started",
644
+ "account",
645
+ "transactional",
646
+ "Help a new user get value: one key feature explained in 3 steps with short texts, a button, and where to find help."
647
+ ),
648
+ purpose(
649
+ "trial-ending",
650
+ "Trial ending",
651
+ "account",
652
+ "transactional",
653
+ 'The free trial ends soon: the end date, what they lose, the plans or price, and an "Upgrade" button.'
654
+ ),
655
+ // Orders and payments
656
+ purpose(
657
+ "order-confirmation",
658
+ "Order confirmation",
659
+ "orders",
660
+ "transactional",
661
+ '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.'
662
+ ),
663
+ purpose(
664
+ "order-shipped",
665
+ "Order shipped",
666
+ "orders",
667
+ "transactional",
668
+ 'The order is on its way: carrier, expected delivery date, a "Track my parcel" button, and the shipped items.'
669
+ ),
670
+ purpose(
671
+ "delivery-delay",
672
+ "Delivery delay",
673
+ "orders",
674
+ "transactional",
675
+ '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.'
676
+ ),
677
+ purpose(
678
+ "receipt",
679
+ "Receipt or invoice",
680
+ "orders",
681
+ "transactional",
682
+ 'A payment receipt or invoice: amount paid, date, payment method, a clear breakdown, and a "Download the invoice" button.'
683
+ ),
684
+ purpose(
685
+ "payment-failed",
686
+ "Payment failed",
687
+ "orders",
688
+ "transactional",
689
+ 'A payment did not go through: calm and helpful, what happens next and when, and an "Update my payment method" button.'
690
+ ),
691
+ purpose(
692
+ "subscription-renewal",
693
+ "Subscription renewal",
694
+ "orders",
695
+ "transactional",
696
+ "An upcoming subscription renewal: plan, amount, renewal date, and buttons to manage the subscription."
697
+ ),
698
+ purpose(
699
+ "refund",
700
+ "Refund issued",
701
+ "orders",
702
+ "transactional",
703
+ "A refund was issued: amount, original order, when it reaches the account, and a contact for questions."
704
+ ),
705
+ // Customer relationship
706
+ purpose(
707
+ "thank-you",
708
+ "Thank you",
709
+ "relationship",
710
+ "marketing",
711
+ "A sincere thank-you (for a purchase, a year together, a donation, a recommendation): personal tone, short, and an optional small gift."
712
+ ),
713
+ purpose(
714
+ "review-request",
715
+ "Review request",
716
+ "relationship",
717
+ "marketing",
718
+ '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.'
719
+ ),
720
+ purpose(
721
+ "survey",
722
+ "Survey",
723
+ "relationship",
724
+ "marketing",
725
+ 'Ask for feedback: how long it takes (for example 2 minutes), why it helps, an optional incentive, and a "Take the survey" button.'
726
+ ),
727
+ purpose(
728
+ "win-back",
729
+ "We miss you",
730
+ "relationship",
731
+ "marketing",
732
+ "Re-engage an inactive customer: what is new since their last visit, a comeback offer, and a button."
733
+ ),
734
+ purpose(
735
+ "follow-up",
736
+ "Follow-up",
737
+ "relationship",
738
+ "transactional",
739
+ "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."
740
+ ),
741
+ purpose(
742
+ "apology",
743
+ "Apology",
744
+ "relationship",
745
+ "transactional",
746
+ "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."
747
+ ),
748
+ // Business and organization
749
+ purpose(
750
+ "hiring",
751
+ "We're hiring",
752
+ "organization",
753
+ "marketing",
754
+ 'Job openings: why join the team, 2 to 4 open roles (title, place, one line), and a "See the jobs" button.'
755
+ ),
756
+ purpose(
757
+ "policy-update",
758
+ "Policy update",
759
+ "organization",
760
+ "transactional",
761
+ "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."
762
+ ),
763
+ purpose(
764
+ "price-change",
765
+ "Price change",
766
+ "organization",
767
+ "transactional",
768
+ "A price change notice: old and new price, the effective date, why, and how to change plan or cancel. Honest and respectful."
769
+ ),
770
+ purpose(
771
+ "maintenance",
772
+ "Scheduled maintenance",
773
+ "organization",
774
+ "transactional",
775
+ "Planned maintenance or an incident: when, how long, what is affected, and where to follow the status."
776
+ ),
777
+ purpose(
778
+ "closure",
779
+ "Closure or opening hours",
780
+ "organization",
781
+ "marketing",
782
+ "Holiday closure or new opening hours: the dates and hours very clearly, what customers can still do, and wishes."
783
+ ),
784
+ purpose(
785
+ "donation-appeal",
786
+ "Donation appeal",
787
+ "organization",
788
+ "marketing",
789
+ 'A fundraising appeal for a nonprofit: one concrete story, what a gift makes possible (amount examples), and a "Donate" button.'
790
+ ),
791
+ purpose(
792
+ "team-update",
793
+ "Internal update",
794
+ "organization",
795
+ "transactional",
796
+ "An internal message to employees: news, dates to remember, and a contact. Plain and friendly."
797
+ )
798
+ ];
799
+ function purposeById(id) {
800
+ return EMAIL_PURPOSES.find((entry) => entry.id === id) || null;
801
+ }
802
+ function purposesByCategory(purposes = EMAIL_PURPOSES) {
803
+ return PURPOSE_CATEGORIES.map((category) => ({
804
+ ...category,
805
+ purposes: purposes.filter((entry) => entry.category === category.id)
806
+ })).filter((group) => group.purposes.length > 0);
807
+ }
808
+
809
+ // src/ai/rewrite.js
810
+ var REWRITE_ACTIONS = [
811
+ { id: "shorter", label: "Shorter", change: "Make it noticeably shorter, about half as long. Keep the meaning, the key facts and every link." },
812
+ {
813
+ id: "longer",
814
+ label: "Longer",
815
+ change: "Make it somewhat longer, about one and a half times, and more vivid. Develop what is there; invent no fact."
816
+ },
817
+ {
818
+ id: "fix",
819
+ label: "Fix mistakes",
820
+ change: "Fix spelling, grammar and punctuation only. Change nothing else: same words, same tone, same length."
821
+ },
822
+ { 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." },
823
+ { id: "formal", label: "More formal", change: "Make the tone more professional and formal. Same facts, about the same length." }
824
+ ];
825
+ var REWRITE_LANGUAGES = ["en", "fr", "de", "es", "it", "pt", "nl", "pl", "uk", "ru", "tr", "ja", "zh"];
826
+ var MAX_REWRITE_TEXT = 6e3;
827
+ var MAX_REWRITE_INSTRUCTION = 300;
828
+ var REWRITE_SCHEMA = {
829
+ type: "object",
830
+ properties: { text: { type: "string", description: "The rewritten text, nothing else." } },
831
+ required: ["text"],
832
+ additionalProperties: false
833
+ };
834
+ 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".
835
+
836
+ The text is one of:
837
+ - 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.
838
+ - plain text, the label of a button: answer plain text without HTML, a short call to action of a few words.
839
+
840
+ Always:
841
+ - Keep every link: each <a href="..."> of the text stays, with its href unchanged; the words of the link may change. Add no link.
842
+ - 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.
843
+ - Keep [placeholders in square brackets]: the user replaces them later. Translate their words when translating.
844
+ - Invent no facts: no new price, date, number, name, guarantee or promise.
845
+ - Write in the language of the text, unless asked to translate.
846
+ - No preamble, no comment, no quotation marks around the text.`;
847
+ var bounded = (value, max) => typeof value === "string" ? value.trim().slice(0, max) : "";
848
+ var wordsOf = (html) => html.replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
849
+ function cleanRewriteInput(input) {
850
+ const source = input && typeof input === "object" ? input : (
851
+ /** @type {RewriteInput} */
852
+ { action: "", text: "" }
853
+ );
854
+ const format = (
855
+ /** @type {'html'|'plain'} */
856
+ source.format === "plain" ? "plain" : "html"
857
+ );
858
+ const text2 = bounded(source.text, MAX_REWRITE_TEXT);
859
+ if (!wordsOf(text2)) throw new EmailGenerationError("NOTHING_TO_REWRITE");
860
+ const preset = REWRITE_ACTIONS.find((action) => action.id === source.action);
861
+ if (preset) return { format, text: text2, change: preset.change };
862
+ if (source.action === "translate") {
863
+ const language = String(source.targetLanguage || "");
864
+ if (!/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(language)) throw new EmailGenerationError("NOTHING_TO_REWRITE", "No target language.");
865
+ return { format, text: text2, change: `Translate it into ${languageName(language)}. Keep the tone and the length.` };
866
+ }
867
+ const instruction = bounded(source.instruction, MAX_REWRITE_INSTRUCTION);
868
+ if (source.action === "custom" && instruction) return { format, text: text2, change: instruction };
869
+ throw new EmailGenerationError("NOTHING_TO_REWRITE", "Unknown action.");
870
+ }
871
+ function buildRewritePrompt({ format, text: text2, change }) {
872
+ return [
873
+ `Change: ${change}`,
874
+ `Format: ${format === "plain" ? "plain text (a button label)" : "HTML (a text block)"}`,
875
+ "",
876
+ "<text>",
877
+ text2,
878
+ "</text>"
879
+ ].join("\n");
880
+ }
881
+ function parseRewrite(answer) {
882
+ const source = String(answer ?? "").trim();
883
+ const unfenced = source.replace(/^```[a-z]*\s*\n?/i, "").replace(/\n?```\s*$/, "").trim();
884
+ if (unfenced.startsWith("{")) {
885
+ try {
886
+ const data = JSON.parse(unfenced);
887
+ if (typeof data?.text === "string") return unescapedTwice(data.text.trim());
888
+ } catch {
889
+ }
890
+ }
891
+ return unfenced.replace(/^<text>\s*/, "").replace(/\s*<\/text>$/, "");
892
+ }
893
+ var hrefsOf = (html) => [...html.matchAll(/<a\b[^>]*?\shref\s*=\s*(["'])(.*?)\1/gi)].map((match) => match[2].trim());
894
+ var variableKeysOf = (html) => [...html.matchAll(/\{\{\s*([^}|]+?)\s*(?:\|[^}]*)?\}\}/g)].map((match) => match[1]);
895
+ function reviewRewrite(original, rewritten, format) {
896
+ const problems = [];
897
+ if (!wordsOf(rewritten)) return ["The answer is empty."];
898
+ const before = new Set(variableKeysOf(original));
899
+ const after = new Set(variableKeysOf(rewritten));
900
+ for (const key of before) if (!after.has(key)) problems.push(`The merge tag {{${key}}} is missing: keep it.`);
901
+ for (const key of after) if (!before.has(key)) problems.push(`The merge tag {{${key}}} was added: remove it.`);
902
+ if (format === "plain") return problems;
903
+ const links = new Set(hrefsOf(original));
904
+ const kept = new Set(hrefsOf(rewritten));
905
+ for (const href of links) if (!kept.has(href)) problems.push(`The link to ${href} is missing: keep it with the same href.`);
906
+ for (const href of kept) if (!links.has(href)) problems.push(`The link to ${href} was added: remove it.`);
907
+ const wrapped = `<mjml><mj-body><mj-section><mj-column><mj-text>${rewritten}</mj-text></mj-column></mj-section></mj-body></mjml>`;
908
+ for (const issue of validateMjml(wrapped).issues) problems.push(issue.message);
909
+ return problems;
910
+ }
911
+ function createRewriter({ model, maxAttempts = 2 }) {
912
+ return async function rewrite(input, { signal } = {}) {
913
+ const request = cleanRewriteInput(input);
914
+ const messages = [{ role: "user", content: buildRewritePrompt(request) }];
915
+ let lastProblem = "";
916
+ for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt += 1) {
917
+ signal?.throwIfAborted();
918
+ let answer;
919
+ try {
920
+ answer = await model({ system: REWRITE_SYSTEM_PROMPT, messages, schema: REWRITE_SCHEMA, signal, task: "rewrite" });
921
+ } catch (error) {
922
+ if (error instanceof EmailGenerationError && error.code === "REFUSED") throw new EmailGenerationError("REWRITE_REFUSED");
923
+ throw error;
924
+ }
925
+ let text2 = parseRewrite(answer);
926
+ if (request.format === "plain") text2 = wordsOf(text2).slice(0, 120);
927
+ const problems = reviewRewrite(request.text, text2, request.format);
928
+ if (!problems.length) return { text: text2, attempts: attempt };
929
+ lastProblem = problems[0];
930
+ messages.push(
931
+ { role: "assistant", content: String(answer) },
932
+ { role: "user", content: `Fix these problems and answer the whole text again:
933
+ ${problems.map((p) => `- ${p}`).join("\n")}` }
934
+ );
935
+ }
936
+ throw new EmailGenerationError("REWRITE_UNUSABLE", lastProblem);
937
+ };
938
+ }
939
+
940
+ // src/ai/subjects.js
941
+ var SUBJECT_COUNT = 3;
942
+ var MAX_SUBJECT_LENGTH = 150;
943
+ var MAX_EMAIL_WORDS = 4e3;
944
+ var MAX_VARIABLES = 60;
945
+ var SUBJECTS_SCHEMA = {
946
+ type: "object",
947
+ properties: {
948
+ subjects: {
949
+ type: "array",
950
+ description: "Exactly three subject lines, plain text.",
951
+ items: { type: "string" }
952
+ }
953
+ },
954
+ required: ["subjects"],
955
+ additionalProperties: false
956
+ };
957
+ 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".
958
+
959
+ Each subject:
960
+ - tells what the email brings, specific to its content: under 50 characters when possible, 70 at most;
961
+ - takes its own angle: one clear and direct, one that makes the reader curious, one personal or timely;
962
+ - is written in the language of the email;
963
+ - invents no facts: no price, discount, date, number, name or promise the email does not contain;
964
+ - is plain text: no HTML, no quotation marks around it, no line break;
965
+ - has no word in capitals, at most one "!", no spam wording ("free!!!", "act now", "100% guaranteed");
966
+ - has no emoji, unless the current subject has one;
967
+ - differs from the current subject and from the two others, and does not repeat the preview text.
968
+
969
+ 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.`;
970
+ var bounded2 = (value, max) => typeof value === "string" ? value.trim().slice(0, max) : "";
971
+ var WRAPPING_QUOTES = { '"': '"', "'": "'", "\xAB": "\xBB", "\u201C": "\u201D", "\u201E": "\u201C", "\u2018": "\u2019", "\u2039": "\u203A" };
972
+ var MARKUP = /<\/?(?:a|abbr|b|big|br|code|del|div|em|font|h[1-6]|i|ins|li|mark|ol|p|q|s|small|span|strike|strong|sub|sup|u|ul)\b[^<>]*>/gi;
973
+ var line = (value) => {
974
+ let text2 = String(value ?? "").replace(MARKUP, "").replace(/\s+/g, " ").trim();
975
+ const close = WRAPPING_QUOTES[
976
+ /** @type {keyof typeof WRAPPING_QUOTES} */
977
+ text2[0]
978
+ ];
979
+ if (close && text2.length > 2 && text2.endsWith(close) && !text2.slice(1, -1).includes(close) && !text2.slice(1, -1).includes(text2[0])) {
980
+ text2 = text2.slice(1, -1).trim();
981
+ }
982
+ return text2.slice(0, MAX_SUBJECT_LENGTH);
983
+ };
984
+ var ENTITIES = {
985
+ amp: "&",
986
+ lt: "<",
987
+ gt: ">",
988
+ quot: '"',
989
+ apos: "'",
990
+ nbsp: " ",
991
+ rsquo: "\u2019",
992
+ lsquo: "\u2018",
993
+ rdquo: "\u201D",
994
+ ldquo: "\u201C",
995
+ laquo: "\xAB",
996
+ raquo: "\xBB",
997
+ bdquo: "\u201E",
998
+ sbquo: "\u201A",
999
+ hellip: "\u2026",
1000
+ mdash: "\u2014",
1001
+ ndash: "\u2013",
1002
+ middot: "\xB7",
1003
+ bull: "\u2022",
1004
+ copy: "\xA9",
1005
+ reg: "\xAE",
1006
+ trade: "\u2122",
1007
+ euro: "\u20AC",
1008
+ pound: "\xA3",
1009
+ yen: "\xA5",
1010
+ cent: "\xA2",
1011
+ deg: "\xB0",
1012
+ times: "\xD7",
1013
+ shy: "",
1014
+ eacute: "\xE9",
1015
+ egrave: "\xE8",
1016
+ ecirc: "\xEA",
1017
+ euml: "\xEB",
1018
+ agrave: "\xE0",
1019
+ aacute: "\xE1",
1020
+ acirc: "\xE2",
1021
+ auml: "\xE4",
1022
+ atilde: "\xE3",
1023
+ aring: "\xE5",
1024
+ ccedil: "\xE7",
1025
+ iacute: "\xED",
1026
+ igrave: "\xEC",
1027
+ icirc: "\xEE",
1028
+ iuml: "\xEF",
1029
+ oacute: "\xF3",
1030
+ ograve: "\xF2",
1031
+ ocirc: "\xF4",
1032
+ ouml: "\xF6",
1033
+ otilde: "\xF5",
1034
+ oslash: "\xF8",
1035
+ uacute: "\xFA",
1036
+ ugrave: "\xF9",
1037
+ ucirc: "\xFB",
1038
+ uuml: "\xFC",
1039
+ ntilde: "\xF1",
1040
+ szlig: "\xDF",
1041
+ aelig: "\xE6",
1042
+ oelig: "\u0153",
1043
+ yuml: "\xFF",
1044
+ Eacute: "\xC9",
1045
+ Egrave: "\xC8",
1046
+ Ecirc: "\xCA",
1047
+ Agrave: "\xC0",
1048
+ Aacute: "\xC1",
1049
+ Acirc: "\xC2",
1050
+ Auml: "\xC4",
1051
+ Ccedil: "\xC7",
1052
+ Ocirc: "\xD4",
1053
+ Ouml: "\xD6",
1054
+ Uuml: "\xDC",
1055
+ Ntilde: "\xD1",
1056
+ OElig: "\u0152",
1057
+ AElig: "\xC6",
1058
+ iexcl: "\xA1",
1059
+ iquest: "\xBF"
1060
+ };
1061
+ var entity = (whole, body) => {
1062
+ if (body[0] !== "#") return Object.hasOwn(ENTITIES, body) ? ENTITIES[
1063
+ /** @type {keyof typeof ENTITIES} */
1064
+ body
1065
+ ] : whole;
1066
+ const code = /^#x/i.test(body) ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
1067
+ return Number.isInteger(code) && code > 0 && code <= 1114111 && !(code >= 55296 && code <= 57343) ? String.fromCodePoint(code) : whole;
1068
+ };
1069
+ function emailWords(mjml) {
1070
+ 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(/&(#\d{1,8}|#x[0-9a-f]{1,8}|[a-z]{2,8});/gi, entity).replace(/[ \t]+/g, " ").replace(/\s*\n\s*/g, "\n").trim().slice(0, MAX_EMAIL_WORDS);
1071
+ }
1072
+ function cleanSubjectsInput(input) {
1073
+ const source = input && typeof input === "object" ? input : {};
1074
+ const subject = line(source.subject);
1075
+ const preheader = bounded2(source.preheader, 300).replace(/\s+/g, " ");
1076
+ const text2 = bounded2(source.text, MAX_EMAIL_WORDS);
1077
+ if (!subject && !text2) throw new EmailGenerationError("NOTHING_TO_SUGGEST");
1078
+ const language = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i.test(String(source.language || "")) ? String(source.language).toLowerCase() : "en";
1079
+ 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) => (
1080
+ /** @type {any} */
1081
+ variable.kind !== "link"
1082
+ )).slice(0, MAX_VARIABLES).map((variable) => ({ key: String(variable.key), label: bounded2(variable.label, 80) }));
1083
+ const avoid = (Array.isArray(source.avoid) ? source.avoid : []).map(line).filter(Boolean).slice(0, 12);
1084
+ return { subject, preheader, text: text2, language, variables, avoid };
1085
+ }
1086
+ function buildSubjectsPrompt({ subject, preheader, text: text2, language, variables, avoid }) {
1087
+ return [
1088
+ `Current subject: ${subject || "(none yet)"}`,
1089
+ `Preview text: ${preheader || "(none)"}`,
1090
+ avoid.length ? `Already suggested, do not repeat: ${avoid.map((idea) => `"${idea}"`).join(", ")}` : null,
1091
+ `Merge tags you may use: ${variables.length ? variables.map((v) => `{{${v.key}}}${v.label ? ` (${v.label})` : ""}`).join(", ") : "none"}`,
1092
+ text2 ? null : `The email has no words yet: write in the language of its subject (${languageName(language)} if the subject does not tell).`,
1093
+ "",
1094
+ "<email>",
1095
+ text2,
1096
+ "</email>"
1097
+ ].filter((row) => row !== null).join("\n");
1098
+ }
1099
+ var capitalized = (subject, language) => {
1100
+ const at = /^[¿¡«"“„'‘(\s]*/.exec(subject)?.[0].length ?? 0;
1101
+ const first = subject.slice(at).split(/\s/)[0];
1102
+ if (!/^\p{Ll}/u.test(first) || /\p{Lu}/u.test(first)) return subject;
1103
+ let upper;
1104
+ try {
1105
+ upper = subject[at].toLocaleUpperCase(language);
1106
+ } catch {
1107
+ upper = subject[at].toUpperCase();
1108
+ }
1109
+ return subject.slice(0, at) + upper + subject.slice(at + 1);
1110
+ };
1111
+ var notASubject = (row) => {
1112
+ const words = row.replace(/\{\{[^{}]*\}\}/g, "");
1113
+ return /[::]$/.test(row) || (words.match(/\p{L}/gu) || []).length < 2 || /^[[\]{}]|[{}[\]],?$/.test(words);
1114
+ };
1115
+ function parseSubjects(answer, language) {
1116
+ const source = String(answer ?? "").trim();
1117
+ const unfenced = source.replace(/^```[a-z]*\s*\n?/i, "").replace(/\n?```\s*$/, "").trim();
1118
+ const shape = (items) => items.filter((item) => typeof item === "string").map((subject) => capitalized(line(subject), language));
1119
+ const start = unfenced.indexOf("{");
1120
+ if (start !== -1) {
1121
+ const end = unfenced.lastIndexOf("}");
1122
+ try {
1123
+ const data = JSON.parse(unfenced.slice(start, end + 1));
1124
+ return Array.isArray(data?.subjects) ? shape(data.subjects) : [];
1125
+ } catch {
1126
+ return [];
1127
+ }
1128
+ }
1129
+ const rows = unfenced.split("\n").map((row) => row.trim()).filter(Boolean);
1130
+ const marked = /^(?:[-*•]|\d+[.)])\s+/;
1131
+ const listed = rows.filter((row) => marked.test(row));
1132
+ return shape((listed.length ? listed : rows).map((row) => line(row.replace(marked, ""))).filter((row) => !notASubject(row)));
1133
+ }
1134
+ var keysOf = (text2) => [...text2.matchAll(/\{\{\s*([^}|]+?)\s*(?:\|[^}]*)?\}\}/g)].map((match) => match[1]);
1135
+ var FOREIGN_TAG = /\*\|[^|]+\|\*|%[A-Za-z_][\w.]*%|(?<!\{)\{[A-Za-z_][\w.]*\}(?!\})/;
1136
+ var MAX_SUBJECT_SHOWN = 90;
1137
+ function reviewSubjects(subjects, request) {
1138
+ const known = new Set(request.variables.map((variable) => variable.key));
1139
+ const seen = new Set([request.subject, request.preheader, ...request.avoid].filter(Boolean).map((subject) => subject.toLocaleLowerCase()));
1140
+ const source = `${request.subject}
1141
+ ${request.preheader}
1142
+ ${request.text}`;
1143
+ const usable = [];
1144
+ const problems = [];
1145
+ for (const subject of subjects) {
1146
+ if (!subject) continue;
1147
+ const unknown = keysOf(subject).filter((key) => !known.has(key));
1148
+ const foreign = FOREIGN_TAG.exec(subject)?.[0];
1149
+ const shouting = (subject.replace(/\{\{[^}]*\}\}/g, "").match(/\b\p{Lu}{4,}\b/gu) || []).find((word) => !source.includes(word));
1150
+ const problem = unknown.length ? `"${subject}" uses {{${unknown[0]}}}, which is not in the list of merge tags.` : foreign ? `"${subject}" writes ${foreign}: merge tags are written {{key}}, from the list only.` : subject.length > MAX_SUBJECT_SHOWN ? `"${subject}" is ${subject.length} characters long: 70 at most.` : shouting ? `"${subject}" writes ${shouting} in capitals.` : (subject.match(/!/g) || []).length > 1 ? `"${subject}" has more than one "!".` : seen.has(subject.toLocaleLowerCase()) ? `"${subject}" repeats the current subject, the preview text or another one.` : null;
1151
+ if (problem) {
1152
+ problems.push(problem);
1153
+ continue;
1154
+ }
1155
+ seen.add(subject.toLocaleLowerCase());
1156
+ usable.push(subject);
1157
+ }
1158
+ if (usable.length < SUBJECT_COUNT && !problems.length) problems.push(`Give exactly ${SUBJECT_COUNT} subjects, in the JSON field "subjects".`);
1159
+ return { usable: usable.slice(0, SUBJECT_COUNT), problems: usable.length >= SUBJECT_COUNT ? [] : problems };
1160
+ }
1161
+ function createSubjectSuggester({ model, maxAttempts = 2 }) {
1162
+ return async function suggestSubjects(input, { signal } = {}) {
1163
+ const request = cleanSubjectsInput(input);
1164
+ const messages = [{ role: "user", content: buildSubjectsPrompt(request) }];
1165
+ let best = [];
1166
+ for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt += 1) {
1167
+ signal?.throwIfAborted();
1168
+ let answer;
1169
+ try {
1170
+ answer = await model({ system: SUBJECTS_SYSTEM_PROMPT, messages, schema: SUBJECTS_SCHEMA, signal, task: "subjects" });
1171
+ } catch (error) {
1172
+ if (error instanceof EmailGenerationError && error.code === "REFUSED") throw new EmailGenerationError("SUBJECTS_REFUSED");
1173
+ throw error;
1174
+ }
1175
+ const { usable, problems } = reviewSubjects(parseSubjects(answer, request.language), request);
1176
+ if (usable.length > best.length) best = usable;
1177
+ if (!problems.length) return { subjects: usable, attempts: attempt };
1178
+ messages.push(
1179
+ { role: "assistant", content: String(answer) },
1180
+ { role: "user", content: `Fix these problems and answer the three subjects again:
1181
+ ${problems.map((p) => `- ${p}`).join("\n")}` }
1182
+ );
1183
+ }
1184
+ if (best.length) return { subjects: best, attempts: Math.max(1, maxAttempts) };
1185
+ throw new EmailGenerationError("SUBJECTS_UNUSABLE");
1186
+ };
1187
+ }
1188
+
1189
+ // src/ai/i18n/fr.js
1190
+ var fr = {
1191
+ "Event invitation": "Invitation",
1192
+ "Newsletter": "Newsletter",
1193
+ "Welcome": "Bienvenue",
1194
+ "Generate": "G\xE9n\xE9rer",
1195
+ "Generate an email": "G\xE9n\xE9rer un e-mail",
1196
+ "Pick what it is for. AI writes a complete email that opens here, ready to edit.": "Choisissez \xE0 quoi il sert. L'IA \xE9crit un e-mail complet qui s'ouvre ici, pr\xEAt \xE0 modifier.",
1197
+ "Search": "Rechercher",
1198
+ "Search: sale, password, birthday\u2026": "Rechercher : soldes, mot de passe, anniversaire\u2026",
1199
+ "What is the email for?": "\xC0 quoi sert l'e-mail ?",
1200
+ "Nothing matches. Describe your email in the details below.": "Aucun r\xE9sultat. D\xE9crivez votre e-mail dans les pr\xE9cisions ci-dessous.",
1201
+ "Details (optional)": "Pr\xE9cisions (facultatif)",
1202
+ "Details (optional): product, dates, offer, tone, language\u2026": "Pr\xE9cisions (facultatif) : produit, dates, offre, ton, langue\u2026",
1203
+ "The email being edited will be replaced, unsaved changes included.": "L'e-mail en cours sera remplac\xE9, modifications non enregistr\xE9es comprises.",
1204
+ "Writing your email\u2026 {seconds} s": "R\xE9daction de votre e-mail\u2026 {seconds} s",
1205
+ "Your email is ready. Edit anything, then save.": "Votre e-mail est pr\xEAt. Modifiez ce que vous voulez, puis enregistrez.",
1206
+ "The email could not be generated.": "L'e-mail n'a pas pu \xEAtre g\xE9n\xE9r\xE9.",
1207
+ "Pick what the email is for, or describe it.": "Choisissez \xE0 quoi sert l'e-mail, ou d\xE9crivez-le.",
1208
+ "The AI declined to write this email. Try other details.": "L'IA a refus\xE9 d'\xE9crire cet e-mail. Essayez d'autres pr\xE9cisions.",
1209
+ "The email was too long to finish. Ask for a shorter one.": "L'e-mail \xE9tait trop long pour \xEAtre termin\xE9. Demandez-en un plus court.",
1210
+ "The AI answer could not be read. Try again.": "La r\xE9ponse de l'IA est illisible. R\xE9essayez.",
1211
+ "The generated email could not be used. Try again.": "L'e-mail g\xE9n\xE9r\xE9 est inutilisable. R\xE9essayez.",
1212
+ "Sales and promotions": "Ventes et promotions",
1213
+ "Holidays and occasions": "F\xEAtes et occasions",
1214
+ "Events and appointments": "\xC9v\xE9nements et rendez-vous",
1215
+ "News and content": "Actualit\xE9s et contenus",
1216
+ "Account and security": "Compte et s\xE9curit\xE9",
1217
+ "Orders and payments": "Commandes et paiements",
1218
+ "Customer relationship": "Relation client",
1219
+ "Business and organization": "Entreprise et organisation",
1220
+ "Store sale": "Soldes en boutique",
1221
+ "Flash sale": "Vente flash",
1222
+ "Discount code": "Code promo",
1223
+ "New product": "Nouveau produit",
1224
+ "Back in stock": "De retour en stock",
1225
+ "Price drop": "Baisse de prix",
1226
+ "Abandoned cart": "Panier abandonn\xE9",
1227
+ "Black Friday": "Black Friday",
1228
+ "Loyalty reward": "R\xE9compense fid\xE9lit\xE9",
1229
+ "Refer a friend": "Parrainage",
1230
+ "Christmas": "No\xEBl",
1231
+ "New Year": "Nouvel An",
1232
+ "Season's greetings": "V\u0153ux de fin d'ann\xE9e",
1233
+ "Valentine's Day": "Saint-Valentin",
1234
+ "Easter": "P\xE2ques",
1235
+ "Mother's Day": "F\xEAte des m\xE8res",
1236
+ "Father's Day": "F\xEAte des p\xE8res",
1237
+ "Halloween": "Halloween",
1238
+ "Back to school": "Rentr\xE9e",
1239
+ "Summer holidays": "Vacances d'\xE9t\xE9",
1240
+ "Birthday wishes": "Joyeux anniversaire",
1241
+ "Customer anniversary": "Anniversaire client",
1242
+ "Women's Day": "Journ\xE9e des femmes",
1243
+ "Thanksgiving": "Thanksgiving",
1244
+ "Lunar New Year": "Nouvel An lunaire",
1245
+ "Ramadan and Eid": "Ramadan et A\xEFd",
1246
+ "Diwali": "Diwali",
1247
+ "Meeting reminder": "Rappel de r\xE9union",
1248
+ "Appointment confirmation": "Confirmation de rendez-vous",
1249
+ "Booking confirmation": "Confirmation de r\xE9servation",
1250
+ "Webinar invitation": "Invitation \xE0 un webinaire",
1251
+ "Save the date": "Save the date",
1252
+ "Registration confirmed": "Inscription confirm\xE9e",
1253
+ "Cancellation or new date": "Annulation ou report",
1254
+ "Thanks for attending": "Merci de votre participation",
1255
+ "New article": "Nouvel article",
1256
+ "What's new": "Nouveaut\xE9s",
1257
+ "Announcement": "Annonce",
1258
+ "Tips and how-to": "Conseils et tutoriels",
1259
+ "Email verification": "V\xE9rification de l'e-mail",
1260
+ "Password reset": "R\xE9initialisation du mot de passe",
1261
+ "Login code": "Code de connexion",
1262
+ "Security alert": "Alerte de s\xE9curit\xE9",
1263
+ "Getting started": "Premiers pas",
1264
+ "Trial ending": "Fin de l'essai",
1265
+ "Order confirmation": "Confirmation de commande",
1266
+ "Order shipped": "Commande exp\xE9di\xE9e",
1267
+ "Delivery delay": "Retard de livraison",
1268
+ "Receipt or invoice": "Re\xE7u ou facture",
1269
+ "Payment failed": "\xC9chec du paiement",
1270
+ "Subscription renewal": "Renouvellement d'abonnement",
1271
+ "Refund issued": "Remboursement effectu\xE9",
1272
+ "Thank you": "Remerciements",
1273
+ "Review request": "Demande d'avis",
1274
+ "Survey": "Enqu\xEAte de satisfaction",
1275
+ "We miss you": "Vous nous manquez",
1276
+ "Follow-up": "Relance",
1277
+ "Apology": "Excuses",
1278
+ "We're hiring": "On recrute",
1279
+ "Policy update": "Mise \xE0 jour des conditions",
1280
+ "Price change": "Changement de prix",
1281
+ "Scheduled maintenance": "Maintenance planifi\xE9e",
1282
+ "Closure or opening hours": "Fermeture ou horaires",
1283
+ "Donation appeal": "Appel aux dons",
1284
+ "Internal update": "Communication interne",
1285
+ "Rewrite with AI": "R\xE9\xE9crire avec l'IA",
1286
+ "Shorter": "Plus court",
1287
+ "Longer": "Plus long",
1288
+ "Fix mistakes": "Corriger les fautes",
1289
+ "Friendlier": "Plus chaleureux",
1290
+ "More formal": "Plus formel",
1291
+ "Translate": "Traduire",
1292
+ "Translate into": "Traduire en",
1293
+ "Or say what to change\u2026": "Ou dites ce qu'il faut changer\u2026",
1294
+ "What to change": "Ce qu'il faut changer",
1295
+ "Rewrite": "R\xE9\xE9crire",
1296
+ "Rewriting\u2026": "R\xE9\xE9criture\u2026",
1297
+ "Suggest subjects": "Sugg\xE9rer des objets",
1298
+ "Subject ideas": "Id\xE9es d'objet",
1299
+ "More ideas": "Autres id\xE9es",
1300
+ "Done": "Termin\xE9",
1301
+ "yours": "le v\xF4tre",
1302
+ "Reading the email\u2026": "Lecture de l'e-mail\u2026",
1303
+ "No subject could be suggested.": "Aucun objet n'a pu \xEAtre sugg\xE9r\xE9.",
1304
+ "Write the email or a subject first: the ideas are drawn from it.": "R\xE9digez d'abord l'e-mail ou un objet : les id\xE9es en sont tir\xE9es.",
1305
+ "The AI declined to suggest subjects for this email.": "L'IA a refus\xE9 de sugg\xE9rer des objets pour cet e-mail.",
1306
+ "No usable subject came back. Try again.": "Aucun objet utilisable n'a \xE9t\xE9 propos\xE9. R\xE9essayez.",
1307
+ "Connect an AI provider first: it needs a key.": "Connectez d'abord un fournisseur d'IA : il faut une cl\xE9.",
1308
+ "The AI provider refused the key. Check it, or use another one.": "Le fournisseur d'IA a refus\xE9 la cl\xE9. V\xE9rifiez-la ou utilisez-en une autre.",
1309
+ "The AI provider account has no credits left.": "Le compte du fournisseur d'IA n'a plus de cr\xE9dits.",
1310
+ "The AI is busy right now. Try again in a minute.": "L'IA est occup\xE9e pour le moment. R\xE9essayez dans une minute.",
1311
+ "The AI provider could not answer. Try again.": "Le fournisseur d'IA n'a pas pu r\xE9pondre. R\xE9essayez.",
1312
+ "Text rewritten. Ctrl+Z brings back the previous one.": "Texte r\xE9\xE9crit. Ctrl+Z r\xE9tablit le pr\xE9c\xE9dent.",
1313
+ "The text changed in the meantime, so it was left as it is.": "Le texte a chang\xE9 entre-temps : il a \xE9t\xE9 laiss\xE9 tel quel.",
1314
+ "The text could not be rewritten.": "Le texte n'a pas pu \xEAtre r\xE9\xE9crit.",
1315
+ "There is no text to rewrite.": "Il n'y a pas de texte \xE0 r\xE9\xE9crire.",
1316
+ "The AI declined to rewrite this text.": "L'IA a refus\xE9 de r\xE9\xE9crire ce texte.",
1317
+ "The rewritten text lost a link or a variable, so nothing was changed. Try again.": "Le texte r\xE9\xE9crit avait perdu un lien ou une variable : rien n'a \xE9t\xE9 modifi\xE9. R\xE9essayez.",
1318
+ "Connect {provider}": "Connecter {provider}",
1319
+ "The AI writes with your own {provider} key. Connect it once: this browser remembers it.": "L'IA \xE9crit avec votre propre cl\xE9 {provider}. Connectez-la une fois : ce navigateur s'en souvient.",
1320
+ "{provider} refused the last key. Paste another one.": "{provider} a refus\xE9 la derni\xE8re cl\xE9. Collez-en une autre.",
1321
+ "Get a key": "Obtenir une cl\xE9",
1322
+ "API key": "Cl\xE9 API",
1323
+ "Paste the key": "Collez la cl\xE9",
1324
+ "Hide the key": "Masquer la cl\xE9",
1325
+ "Show the key": "Afficher la cl\xE9",
1326
+ "{provider} keys usually start with {prefix}.": "Les cl\xE9s {provider} commencent d'habitude par {prefix}.",
1327
+ "{provider} keys start with {prefix}.": "Les cl\xE9s {provider} commencent par {prefix}.",
1328
+ "The key stays in this browser and is sent only to {provider}.": "La cl\xE9 reste dans ce navigateur et n'est envoy\xE9e qu'\xE0 {provider}.",
1329
+ "Connect": "Connecter",
1330
+ "Free models, with a free account": "Mod\xE8les gratuits, avec un compte gratuit",
1331
+ "Free tier, with rate limits": "Offre gratuite, avec des limites de d\xE9bit",
1332
+ 'Free "Experiment" plan': "Offre gratuite \xAB Experiment \xBB",
1333
+ "Runs on your own machine": "Tourne sur votre propre machine"
1334
+ };
1335
+
1336
+ // src/ai/i18n/index.js
1337
+ var AI_MESSAGES = { en: {}, fr };
1338
+
1339
+ // src/ai/generator.js
1340
+ var MAX_DETAILS_LENGTH = 2e3;
1341
+ var MAX_VARIABLES2 = 200;
1342
+ var text = (value, max) => typeof value === "string" ? value.trim().slice(0, max) : "";
1343
+ function cleanGenerateInput(input) {
1344
+ const source = input && typeof input === "object" ? input : {};
1345
+ const purpose2 = purposeById(typeof source.purpose === "string" ? source.purpose : null);
1346
+ const details = text(source.details, MAX_DETAILS_LENGTH);
1347
+ const language = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(String(source.language || "")) ? String(source.language) : "en";
1348
+ const sender = source.sender && typeof source.sender === "object" ? source.sender : {};
1349
+ const logoUrl = text(sender.logoUrl, 2e3);
1350
+ 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) }));
1351
+ const { primary, heading, font } = cleanBrand(source.brand);
1352
+ return {
1353
+ purpose: purpose2,
1354
+ details,
1355
+ language,
1356
+ sender: {
1357
+ name: text(sender.name, 120),
1358
+ address: text(sender.address, 300),
1359
+ logoUrl: logoUrl && /^https:\/\//i.test(logoUrl) && isSafeUrl(logoUrl, "image") ? logoUrl : ""
1360
+ },
1361
+ brand: { ...primary ? { primary } : {}, ...heading ? { heading } : {}, ...font ? { font } : {} },
1362
+ variables
1363
+ };
1364
+ }
1365
+ function createEmailGenerator({ model, maxAttempts = 2, compile } = (
1366
+ /** @type {any} */
1367
+ {}
1368
+ )) {
1369
+ if (typeof model !== "function") throw new Error("createEmailGenerator: `model` must be a function");
1370
+ async function generate(input, { signal } = {}) {
1371
+ const request = cleanGenerateInput(input);
1372
+ if (!request.purpose && !request.details) {
1373
+ throw new EmailGenerationError("NOTHING_TO_GENERATE");
1374
+ }
1375
+ const kind = request.purpose?.kind || "marketing";
1376
+ const keepImages = request.sender.logoUrl ? [request.sender.logoUrl] : [];
1377
+ const messages = [{ role: "user", content: buildUserPrompt(request) }];
1378
+ let usable = null;
1379
+ let lastProblem = "";
1380
+ for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt += 1) {
1381
+ signal?.throwIfAborted();
1382
+ const answer = await model({ system: SYSTEM_PROMPT, messages, schema: EMAIL_DRAFT_SCHEMA, signal, task: "generate" });
1383
+ let problems;
1384
+ try {
1385
+ const draft = parseDraft(answer);
1386
+ draft.mjml = normalizeGeneratedMjml(draft.mjml, { variables: request.variables, keepImages });
1387
+ draft.mjml = applyBrand(draft.mjml, request.brand);
1388
+ const compileWarnings = compile ? (await compile(draft.mjml)).warnings || [] : [];
1389
+ const { blocking, advice } = reviewDraft(draft, { kind, compileWarnings });
1390
+ if (!blocking.length) {
1391
+ usable = { ...draft, warnings: advice, attempts: attempt };
1392
+ if (!advice.length) return usable;
1393
+ }
1394
+ problems = [...blocking, ...advice];
1395
+ } catch (error) {
1396
+ if (!(error instanceof EmailGenerationError)) throw error;
1397
+ problems = [error.detail];
1398
+ }
1399
+ lastProblem = problems[0];
1400
+ messages.push({ role: "assistant", content: String(answer) }, { role: "user", content: buildRepairPrompt(problems) });
1401
+ }
1402
+ if (usable) return usable;
1403
+ throw new EmailGenerationError("INVALID_OUTPUT", lastProblem);
1404
+ }
1405
+ return {
1406
+ generate,
1407
+ rewrite: createRewriter({ model, maxAttempts }),
1408
+ suggestSubjects: createSubjectSuggester({ model, maxAttempts }),
1409
+ // Its error messages in the server's language (the handler merges them).
1410
+ messages: AI_MESSAGES
1411
+ };
1412
+ }
1413
+
1414
+ // src/ai/providers.js
1415
+ var define = (provider) => Object.freeze({ format: "json_schema", tokens: "max_tokens", ...provider });
1416
+ var AI_PROVIDERS = Object.freeze({
1417
+ openrouter: define({
1418
+ id: "openrouter",
1419
+ name: "OpenRouter",
1420
+ baseUrl: "https://openrouter.ai/api/v1",
1421
+ // A router across the free models of the day.
1422
+ model: "openrouter/free",
1423
+ keyUrl: "https://openrouter.ai/keys",
1424
+ keyHint: "sk-or-",
1425
+ free: "Free models, with a free account",
1426
+ headers: { "X-Title": "Best Email Builder" }
1427
+ }),
1428
+ openai: define({
1429
+ id: "openai",
1430
+ name: "OpenAI",
1431
+ baseUrl: "https://api.openai.com/v1",
1432
+ model: "gpt-5-mini",
1433
+ keyUrl: "https://platform.openai.com/api-keys",
1434
+ keyHint: "sk-",
1435
+ free: null,
1436
+ tokens: "max_completion_tokens"
1437
+ }),
1438
+ gemini: define({
1439
+ id: "gemini",
1440
+ name: "Google Gemini",
1441
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
1442
+ model: "gemini-3.5-flash-lite",
1443
+ keyUrl: "https://aistudio.google.com/apikey",
1444
+ keyHint: "AIza",
1445
+ free: "Free tier, with rate limits"
1446
+ }),
1447
+ groq: define({
1448
+ id: "groq",
1449
+ name: "Groq",
1450
+ baseUrl: "https://api.groq.com/openai/v1",
1451
+ model: "openai/gpt-oss-120b",
1452
+ keyUrl: "https://console.groq.com/keys",
1453
+ keyHint: "gsk_",
1454
+ free: "Free tier, with rate limits"
1455
+ }),
1456
+ mistral: define({
1457
+ id: "mistral",
1458
+ name: "Mistral",
1459
+ baseUrl: "https://api.mistral.ai/v1",
1460
+ model: "mistral-small-latest",
1461
+ keyUrl: "https://console.mistral.ai/api-keys",
1462
+ keyHint: "",
1463
+ free: 'Free "Experiment" plan'
1464
+ }),
1465
+ deepseek: define({
1466
+ id: "deepseek",
1467
+ name: "DeepSeek",
1468
+ baseUrl: "https://api.deepseek.com/v1",
1469
+ model: "deepseek-chat",
1470
+ keyUrl: "https://platform.deepseek.com/api_keys",
1471
+ keyHint: "sk-",
1472
+ free: null,
1473
+ format: "json_object"
1474
+ }),
1475
+ cerebras: define({
1476
+ id: "cerebras",
1477
+ name: "Cerebras",
1478
+ baseUrl: "https://api.cerebras.ai/v1",
1479
+ model: "gpt-oss-120b",
1480
+ keyUrl: "https://cloud.cerebras.ai/",
1481
+ keyHint: "csk-",
1482
+ free: "Free tier, with rate limits"
1483
+ }),
1484
+ ollama: define({
1485
+ id: "ollama",
1486
+ name: "Ollama",
1487
+ baseUrl: "http://localhost:11434/v1",
1488
+ model: null,
1489
+ keyUrl: null,
1490
+ keyHint: "",
1491
+ free: "Runs on your own machine",
1492
+ keyless: true
1493
+ }),
1494
+ "openai-compatible": define({
1495
+ id: "openai-compatible",
1496
+ name: "OpenAI-compatible API",
1497
+ baseUrl: "",
1498
+ model: null,
1499
+ keyUrl: null,
1500
+ keyHint: "",
1501
+ free: null,
1502
+ keyless: true
1503
+ })
1504
+ });
1505
+ function resolveProvider(provider) {
1506
+ if (typeof provider === "string") {
1507
+ const known = AI_PROVIDERS[
1508
+ /** @type {keyof typeof AI_PROVIDERS} */
1509
+ provider
1510
+ ];
1511
+ if (!known || !Object.hasOwn(AI_PROVIDERS, provider)) {
1512
+ throw new TypeError(`Unknown AI provider "${provider}" \u2014 one of ${Object.keys(AI_PROVIDERS).join(", ")}, or { baseUrl, model }`);
1513
+ }
1514
+ return known;
1515
+ }
1516
+ if (!provider || typeof provider !== "object") throw new TypeError('provider: an id (e.g. "openrouter") or { baseUrl, model }');
1517
+ const base = provider.id && Object.hasOwn(AI_PROVIDERS, provider.id) ? AI_PROVIDERS[
1518
+ /** @type {keyof typeof AI_PROVIDERS} */
1519
+ provider.id
1520
+ ] : AI_PROVIDERS["openai-compatible"];
1521
+ return define({ ...base, ...provider });
1522
+ }
1523
+ var answerInstruction = (schema) => `
1524
+
1525
+ # Answer format
1526
+ Answer with ONE JSON object and nothing else \u2014 no prose, no code fence \u2014 matching this JSON schema:
1527
+ ${JSON.stringify(schema)}`;
1528
+ function responseFormat(format, schema, task) {
1529
+ if (format === "json_schema") return { type: "json_schema", json_schema: { name: task || "answer", strict: true, schema } };
1530
+ if (format === "json_object") return { type: "json_object" };
1531
+ return void 0;
1532
+ }
1533
+ var formatRefused = (status, text2) => (status === 400 || status === 422) && /response_format|json_schema|json_object|structured|schema|grammar/i.test(text2);
1534
+ function textOf(message) {
1535
+ const content = message?.content;
1536
+ if (typeof content === "string") return content;
1537
+ if (Array.isArray(content)) return content.map((part) => typeof part === "string" ? part : part?.text || "").join("");
1538
+ return "";
1539
+ }
1540
+ function providerEmailModel(options = {}) {
1541
+ const { apiKey, maxTokens = 16e3, temperature, fetch: fetchImpl } = options;
1542
+ const provider = resolveProvider(options.provider ?? "openai-compatible");
1543
+ const model = options.model || provider.model;
1544
+ const baseUrl = String(options.baseUrl || provider.baseUrl).replace(/\/+$/, "");
1545
+ if (!baseUrl) throw new TypeError(`providerEmailModel: ${provider.name} needs a \`baseUrl\``);
1546
+ if (!model) throw new TypeError(`providerEmailModel: ${provider.name} needs a \`model\` (the name it gives the model)`);
1547
+ if (!apiKey && !provider.keyless) {
1548
+ throw new TypeError(`providerEmailModel: ${provider.name} needs an \`apiKey\`${provider.keyUrl ? ` (${provider.keyUrl})` : ""}`);
1549
+ }
1550
+ const call = fetchImpl || globalThis.fetch;
1551
+ let format = provider.format;
1552
+ const emailModel = async ({ system, messages, schema, signal, task }) => {
1553
+ const key = typeof apiKey === "function" ? await apiKey() : apiKey;
1554
+ signal?.throwIfAborted();
1555
+ if (!key && !provider.keyless) throw new EmailGenerationError("KEY_MISSING", `${provider.name}: no key`);
1556
+ for (; ; ) {
1557
+ const body = {
1558
+ model,
1559
+ messages: [{ role: "system", content: system + answerInstruction(schema) }, ...messages],
1560
+ [provider.tokens]: maxTokens,
1561
+ ...temperature === void 0 ? {} : { temperature },
1562
+ ...responseFormat(format, schema, task || "answer") ? { response_format: responseFormat(format, schema, task || "answer") } : {}
1563
+ };
1564
+ let response;
1565
+ try {
1566
+ response = await call(`${baseUrl}/chat/completions`, {
1567
+ method: "POST",
1568
+ headers: {
1569
+ "Content-Type": "application/json",
1570
+ ...key ? { Authorization: `Bearer ${key}` } : {},
1571
+ ...provider.headers,
1572
+ ...options.headers
1573
+ },
1574
+ body: JSON.stringify(body),
1575
+ signal
1576
+ });
1577
+ } catch (error) {
1578
+ if (signal?.aborted || /** @type {any} */
1579
+ error?.name === "AbortError") throw error;
1580
+ throw new EmailGenerationError("MODEL_ERROR", `${provider.name}: ${/** @type {any} */
1581
+ error?.message || error}`);
1582
+ }
1583
+ const text2 = await response.text();
1584
+ if (!response.ok) {
1585
+ if (formatRefused(response.status, text2) && format !== "text") {
1586
+ format = format === "json_schema" ? "json_object" : "text";
1587
+ continue;
1588
+ }
1589
+ 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);
1594
+ }
1595
+ let data;
1596
+ try {
1597
+ data = JSON.parse(text2);
1598
+ } catch {
1599
+ throw new EmailGenerationError("MODEL_ERROR", `${provider.name}: not JSON \u2014 ${text2.slice(0, 200)}`);
1600
+ }
1601
+ if (data?.error) {
1602
+ const code = Number(data.error.code) || 0;
1603
+ 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);
1607
+ }
1608
+ const choice = data?.choices?.[0];
1609
+ if (choice?.message?.refusal) throw new EmailGenerationError("REFUSED", String(choice.message.refusal));
1610
+ if (choice?.finish_reason === "content_filter") throw new EmailGenerationError("REFUSED");
1611
+ if (choice?.finish_reason === "length") throw new EmailGenerationError("TRUNCATED");
1612
+ return textOf(choice?.message);
1613
+ }
1614
+ };
1615
+ return Object.assign(emailModel, { provider, model });
1616
+ }
1617
+
1618
+ // src/ai/claude.js
1619
+ var DEFAULT_CLAUDE_MODEL = "claude-opus-5-5";
1620
+ function claudeEmailModel({
1621
+ client,
1622
+ model = DEFAULT_CLAUDE_MODEL,
1623
+ effort = "medium",
1624
+ rewriteEffort = "low",
1625
+ maxTokens = 32e3
1626
+ } = (
1627
+ /** @type {any} */
1628
+ {}
1629
+ )) {
1630
+ if (!client?.messages?.stream) {
1631
+ throw new Error("claudeEmailModel: pass an Anthropic client \u2014 `new Anthropic()` from @anthropic-ai/sdk");
1632
+ }
1633
+ 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();
1644
+ if (message.stop_reason === "refusal") {
1645
+ throw new EmailGenerationError("REFUSED");
1646
+ }
1647
+ if (message.stop_reason === "max_tokens") {
1648
+ throw new EmailGenerationError("TRUNCATED");
1649
+ }
1650
+ return message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
1651
+ };
1652
+ }
1653
+
1654
+ export {
1655
+ GENERATION_ERRORS,
1656
+ EmailGenerationError,
1657
+ parseDraft,
1658
+ normalizeGeneratedMjml,
1659
+ reviewDraft,
1660
+ EMAIL_DRAFT_SCHEMA,
1661
+ SYSTEM_PROMPT,
1662
+ buildUserPrompt,
1663
+ PURPOSE_CATEGORIES,
1664
+ EMAIL_PURPOSES,
1665
+ purposeById,
1666
+ purposesByCategory,
1667
+ REWRITE_ACTIONS,
1668
+ REWRITE_LANGUAGES,
1669
+ MAX_REWRITE_INSTRUCTION,
1670
+ REWRITE_SYSTEM_PROMPT,
1671
+ reviewRewrite,
1672
+ createRewriter,
1673
+ SUBJECTS_SYSTEM_PROMPT,
1674
+ emailWords,
1675
+ reviewSubjects,
1676
+ createSubjectSuggester,
1677
+ AI_MESSAGES,
1678
+ MAX_DETAILS_LENGTH,
1679
+ cleanGenerateInput,
1680
+ createEmailGenerator,
1681
+ AI_PROVIDERS,
1682
+ resolveProvider,
1683
+ providerEmailModel,
1684
+ DEFAULT_CLAUDE_MODEL,
1685
+ claudeEmailModel
1686
+ };
1687
+ //# sourceMappingURL=chunk-MPX5CWTZ.js.map