best-email-builder 0.4.0 → 0.5.1

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 (83) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +130 -44
  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-2RKVJQSD.js +595 -0
  9. package/dist/chunk-2RKVJQSD.js.map +1 -0
  10. package/dist/{chunk-NAEGU6PR.js → chunk-2Y7CDXZM.js} +18 -29
  11. package/dist/chunk-2Y7CDXZM.js.map +1 -0
  12. package/dist/chunk-4U7L2NYQ.js +356 -0
  13. package/dist/chunk-4U7L2NYQ.js.map +1 -0
  14. package/dist/{chunk-MY6WPDT4.js → chunk-7QQH6MTV.js} +473 -994
  15. package/dist/chunk-7QQH6MTV.js.map +1 -0
  16. package/dist/{chunk-JNHFZXDL.js → chunk-BAA5XMWY.js} +645 -646
  17. package/dist/chunk-BAA5XMWY.js.map +1 -0
  18. package/dist/{chunk-TGDZ4NFI.js → chunk-CNJZLZRH.js} +3 -44
  19. package/dist/chunk-CNJZLZRH.js.map +1 -0
  20. package/dist/chunk-H4W65GBX.js +119 -0
  21. package/dist/chunk-H4W65GBX.js.map +1 -0
  22. package/dist/{chunk-HHLOE6OZ.js → chunk-JI36FNWE.js} +12 -333
  23. package/dist/chunk-JI36FNWE.js.map +1 -0
  24. package/dist/{chunk-AT73Y7JA.js → chunk-N7DEVAIP.js} +49 -581
  25. package/dist/chunk-N7DEVAIP.js.map +1 -0
  26. package/dist/chunk-OWVXKMLI.js +121 -0
  27. package/dist/chunk-OWVXKMLI.js.map +1 -0
  28. package/dist/chunk-SAIW46IC.js +98 -0
  29. package/dist/chunk-SAIW46IC.js.map +1 -0
  30. package/dist/chunk-UFKCLU2D.js +255 -0
  31. package/dist/chunk-UFKCLU2D.js.map +1 -0
  32. package/dist/chunk-W424KPCR.js +251 -0
  33. package/dist/chunk-W424KPCR.js.map +1 -0
  34. package/dist/index.js +31 -37
  35. package/dist/index.js.map +1 -1
  36. package/dist/library.js +12 -7
  37. package/dist/server.js +40 -98
  38. package/dist/server.js.map +1 -1
  39. package/dist/styles.css +1 -1
  40. package/dist/types/ai/ConnectAi.d.ts +12 -0
  41. package/dist/types/ai/GenerateButton.d.ts +4 -0
  42. package/dist/types/ai/SubjectIdeas.d.ts +25 -0
  43. package/dist/types/ai/createAi.d.ts +66 -0
  44. package/dist/types/{features/generate → ai}/draft.d.ts +56 -3
  45. package/dist/types/{features/generate → ai}/generator.d.ts +7 -2
  46. package/dist/types/ai/i18n/fr.d.ts +2 -0
  47. package/dist/types/ai/i18n/index.d.ts +2 -0
  48. package/dist/types/ai/index.d.ts +9 -0
  49. package/dist/types/ai/keys.d.ts +46 -0
  50. package/dist/types/ai/providers.d.ts +75 -0
  51. package/dist/types/{features/generate → ai}/rewrite.d.ts +1 -1
  52. package/dist/types/ai/server.d.ts +9 -0
  53. package/dist/types/{features/generate → ai}/subjects.d.ts +18 -4
  54. package/dist/types/core/constants.d.ts +1 -1
  55. package/dist/types/engine/NativeAdapter.d.ts +1 -1
  56. package/dist/types/features/preview/darkMode.d.ts +33 -16
  57. package/dist/types/features/variables/VariablePicker.d.ts +3 -2
  58. package/dist/types/i18n/index.d.ts +8 -0
  59. package/dist/types/index.d.ts +1 -7
  60. package/dist/types/renderer/prepareMjml.d.ts +8 -2
  61. package/dist/types/server/handler.d.ts +5 -4
  62. package/dist/types/server/index.d.ts +2 -9
  63. package/dist/types/server/render.d.ts +5 -2
  64. package/dist/types/storage/client.d.ts +22 -40
  65. package/dist/types/ui/EmailBuilder.d.ts +5 -1
  66. package/dist/types/ui/EmailStudio.d.ts +6 -1
  67. package/dist/types/ui/PropertiesPanel.d.ts +2 -1
  68. package/dist/types/ui/primitives/searchShortcut.d.ts +16 -0
  69. package/package.json +15 -1
  70. package/dist/chunk-AT73Y7JA.js.map +0 -1
  71. package/dist/chunk-HHLOE6OZ.js.map +0 -1
  72. package/dist/chunk-JNHFZXDL.js.map +0 -1
  73. package/dist/chunk-MY6WPDT4.js.map +0 -1
  74. package/dist/chunk-NAEGU6PR.js.map +0 -1
  75. package/dist/chunk-TGDZ4NFI.js.map +0 -1
  76. package/dist/types/features/generate/GenerateButton.d.ts +0 -3
  77. package/dist/types/features/generate/SubjectIdeas.d.ts +0 -16
  78. package/dist/types/features/generate/index.d.ts +0 -2
  79. /package/dist/types/{features/generate → ai}/RewriteText.d.ts +0 -0
  80. /package/dist/types/{features/generate → ai}/chip.d.ts +0 -0
  81. /package/dist/types/{features/generate → ai}/claude.d.ts +0 -0
  82. /package/dist/types/{features/generate → ai}/prompt.d.ts +0 -0
  83. /package/dist/types/{features/generate → ai}/purposes.d.ts +0 -0
package/dist/ai.js ADDED
@@ -0,0 +1,852 @@
1
+ import {
2
+ cleanRichText
3
+ } from "./chunk-SAIW46IC.js";
4
+ import {
5
+ Button,
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ DropdownMenu,
13
+ DropdownMenuContent,
14
+ DropdownMenuItem,
15
+ DropdownMenuTrigger,
16
+ Input,
17
+ Label,
18
+ SEARCH_KEYS,
19
+ SearchKeyHint,
20
+ SectionLabel,
21
+ Textarea,
22
+ cn,
23
+ createHttpCaller,
24
+ useSearchShortcut,
25
+ useUi
26
+ } from "./chunk-N7DEVAIP.js";
27
+ import "./chunk-H4W65GBX.js";
28
+ import {
29
+ AI_MESSAGES,
30
+ AI_PROVIDERS,
31
+ DEFAULT_CLAUDE_MODEL,
32
+ EMAIL_PURPOSES,
33
+ EmailGenerationError,
34
+ GENERATION_ERRORS,
35
+ MAX_DETAILS_LENGTH,
36
+ MAX_REWRITE_INSTRUCTION,
37
+ PURPOSE_CATEGORIES,
38
+ REWRITE_ACTIONS,
39
+ REWRITE_LANGUAGES,
40
+ claudeEmailModel,
41
+ createEmailGenerator,
42
+ emailWords,
43
+ providerEmailModel,
44
+ purposesByCategory,
45
+ resolveProvider
46
+ } from "./chunk-BAA5XMWY.js";
47
+ import "./chunk-OWVXKMLI.js";
48
+ import "./chunk-W424KPCR.js";
49
+ import {
50
+ brandTemplate,
51
+ hasBrand
52
+ } from "./chunk-JI36FNWE.js";
53
+
54
+ // src/ai/keys.js
55
+ var STORAGE_PREFIX = "best-email-builder:ai-key:";
56
+ function browserStorage() {
57
+ try {
58
+ const storage = globalThis.localStorage;
59
+ return storage && typeof storage.getItem === "function" ? storage : null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+ function createKeyStore(provider) {
65
+ const slot = STORAGE_PREFIX + provider.id;
66
+ let memory = "";
67
+ let refused = false;
68
+ let pending = null;
69
+ const listeners = /* @__PURE__ */ new Set();
70
+ let snapshot;
71
+ const read = () => {
72
+ try {
73
+ return browserStorage()?.getItem(slot) || "";
74
+ } catch {
75
+ return "";
76
+ }
77
+ };
78
+ const write = (value) => {
79
+ try {
80
+ const storage = browserStorage();
81
+ if (value) storage?.setItem(slot, value);
82
+ else storage?.removeItem(slot);
83
+ } catch {
84
+ }
85
+ };
86
+ const get = () => memory || read();
87
+ const changed = () => {
88
+ snapshot = Object.freeze({ asking: Boolean(pending), refused, connected: Boolean(get()) });
89
+ for (const listener of [...listeners]) listener();
90
+ };
91
+ snapshot = Object.freeze({ asking: false, refused: false, connected: Boolean(get()) });
92
+ const answer = (key) => {
93
+ const value = String(key || "").trim();
94
+ if (value) {
95
+ memory = value;
96
+ refused = false;
97
+ write(value);
98
+ }
99
+ const waiting = pending;
100
+ pending = null;
101
+ changed();
102
+ waiting?.resolve(value);
103
+ };
104
+ return Object.freeze({
105
+ provider: Object.freeze({ id: provider.id, name: provider.name, keyUrl: provider.keyUrl, keyHint: provider.keyHint, free: provider.free }),
106
+ get,
107
+ set: (key) => {
108
+ if (typeof key !== "string" || !key.trim()) throw new TypeError("ai.key.set: a non-empty key");
109
+ answer(key);
110
+ },
111
+ forget: () => {
112
+ memory = "";
113
+ write("");
114
+ changed();
115
+ },
116
+ request: () => {
117
+ if (!pending) {
118
+ let resolve = () => {
119
+ };
120
+ const promise = new Promise((done) => resolve = done);
121
+ pending = { promise, resolve };
122
+ changed();
123
+ }
124
+ return pending.promise;
125
+ },
126
+ subscribe: (listener) => {
127
+ listeners.add(listener);
128
+ return () => listeners.delete(listener);
129
+ },
130
+ state: () => snapshot,
131
+ answer: (key) => answer(key || ""),
132
+ refuse: () => {
133
+ memory = "";
134
+ write("");
135
+ refused = true;
136
+ changed();
137
+ }
138
+ });
139
+ }
140
+
141
+ // src/ai/ConnectAi.jsx
142
+ import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
143
+ import { ArrowUpRight, Eye, EyeOff, Gift, KeyRound, ShieldCheck, TriangleAlert } from "lucide-react";
144
+ import { jsx, jsxs } from "react/jsx-runtime";
145
+ function ConnectAi({ aiKey }) {
146
+ const { t } = useUi();
147
+ const state = useSyncExternalStore(aiKey.subscribe, aiKey.state, aiKey.state);
148
+ const [value, setValue] = useState("");
149
+ const [shown, setShown] = useState(false);
150
+ const inputRef = useRef(
151
+ /** @type {HTMLInputElement|null} */
152
+ null
153
+ );
154
+ const inputId = useId();
155
+ const hintId = useId();
156
+ const { name, keyUrl, keyHint, free } = aiKey.provider;
157
+ useEffect(() => {
158
+ if (!state.asking) return;
159
+ setValue("");
160
+ setShown(false);
161
+ }, [state.asking]);
162
+ const key = value.trim();
163
+ const unusual = Boolean(key && keyHint && !key.startsWith(keyHint));
164
+ const submit = (event) => {
165
+ event.preventDefault();
166
+ if (key) aiKey.answer(key);
167
+ };
168
+ return /* @__PURE__ */ jsx(Dialog, { open: state.asking, onOpenChange: (open) => !open && aiKey.answer(null), children: /* @__PURE__ */ jsx(
169
+ DialogContent,
170
+ {
171
+ className: "eb:gap-0 eb:overflow-hidden eb:p-0 eb:sm:max-w-md",
172
+ "data-testid": "eb-ai-connect",
173
+ onOpenAutoFocus: (event) => {
174
+ event.preventDefault();
175
+ inputRef.current?.focus();
176
+ },
177
+ children: /* @__PURE__ */ jsxs("form", { onSubmit: submit, children: [
178
+ /* @__PURE__ */ jsxs("div", { className: "eb:flex eb:flex-col eb:gap-5 eb:px-6 eb:pt-6 eb:pb-5", children: [
179
+ /* @__PURE__ */ jsxs(DialogHeader, { className: "eb:flex-row eb:items-start eb:gap-3.5 eb:pr-6", children: [
180
+ /* @__PURE__ */ jsx("span", { className: "eb:flex eb:size-10 eb:shrink-0 eb:items-center eb:justify-center eb:rounded-full eb:bg-primary/10 eb:text-primary", children: /* @__PURE__ */ jsx(KeyRound, { className: "eb:size-5", "aria-hidden": "true" }) }),
181
+ /* @__PURE__ */ jsxs("div", { className: "eb:flex eb:flex-col eb:gap-1.5", children: [
182
+ /* @__PURE__ */ jsx(DialogTitle, { children: t("Connect {provider}", { provider: name }) }),
183
+ /* @__PURE__ */ jsx(DialogDescription, { className: "eb:text-[13px] eb:leading-relaxed eb:text-foreground-lighter", children: t("The AI writes with your own {provider} key. Connect it once: this browser remembers it.", { provider: name }) })
184
+ ] })
185
+ ] }),
186
+ state.refused && /* @__PURE__ */ jsxs("p", { role: "alert", className: "eb:flex eb:items-start eb:gap-2 eb:rounded-md eb:border eb:border-destructive/30 eb:bg-destructive/10 eb:px-3 eb:py-2 eb:text-[12.5px] eb:text-destructive", children: [
187
+ /* @__PURE__ */ jsx(TriangleAlert, { className: "eb:mt-px eb:size-3.5 eb:shrink-0", "aria-hidden": "true" }),
188
+ t("{provider} refused the last key. Paste another one.", { provider: name })
189
+ ] }),
190
+ (keyUrl || free) && /* @__PURE__ */ jsxs("div", { className: "eb:flex eb:flex-wrap eb:items-center eb:gap-x-3 eb:gap-y-2", children: [
191
+ keyUrl && /* @__PURE__ */ jsx(Button, { asChild: true, variant: "outline", size: "sm-compact", children: /* @__PURE__ */ jsxs("a", { href: keyUrl, target: "_blank", rel: "noopener noreferrer", "data-testid": "eb-ai-connect-get-key", children: [
192
+ t("Get a key"),
193
+ /* @__PURE__ */ jsx(ArrowUpRight, { "aria-hidden": "true" })
194
+ ] }) }),
195
+ free && /* @__PURE__ */ jsxs("span", { className: "eb:inline-flex eb:items-center eb:gap-1.5 eb:rounded-full eb:bg-success/10 eb:px-2.5 eb:py-1 eb:text-[11.5px] eb:font-medium eb:text-success", children: [
196
+ /* @__PURE__ */ jsx(Gift, { className: "eb:size-3.5", "aria-hidden": "true" }),
197
+ t(free)
198
+ ] })
199
+ ] }),
200
+ /* @__PURE__ */ jsxs("div", { className: "eb:flex eb:flex-col eb:gap-1.5", children: [
201
+ /* @__PURE__ */ jsx(Label, { htmlFor: inputId, children: t("API key") }),
202
+ /* @__PURE__ */ jsxs("div", { className: "eb:relative", children: [
203
+ /* @__PURE__ */ jsx(
204
+ Input,
205
+ {
206
+ ref: inputRef,
207
+ id: inputId,
208
+ type: shown ? "text" : "password",
209
+ value,
210
+ onChange: (event) => setValue(event.target.value),
211
+ placeholder: keyHint ? `${keyHint}\u2026` : t("Paste the key"),
212
+ autoComplete: "off",
213
+ autoCapitalize: "off",
214
+ autoCorrect: "off",
215
+ spellCheck: false,
216
+ "aria-describedby": hintId,
217
+ className: "eb:h-9 eb:pr-10 eb:font-mono eb:text-[12.5px]",
218
+ "data-testid": "eb-ai-connect-key"
219
+ }
220
+ ),
221
+ /* @__PURE__ */ jsx(
222
+ "button",
223
+ {
224
+ type: "button",
225
+ className: "eb:absolute eb:top-1/2 eb:right-1 eb:flex eb:size-7 eb:-translate-y-1/2 eb:items-center eb:justify-center eb:rounded eb:text-foreground-muted eb:transition-colors eb:outline-none eb:hover:bg-muted eb:hover:text-foreground eb:focus-visible:ring-[3px] eb:focus-visible:ring-ring/50",
226
+ onClick: () => setShown((current) => !current),
227
+ "aria-label": shown ? t("Hide the key") : t("Show the key"),
228
+ "aria-pressed": shown,
229
+ children: shown ? /* @__PURE__ */ jsx(EyeOff, { className: "eb:size-4", "aria-hidden": "true" }) : /* @__PURE__ */ jsx(Eye, { className: "eb:size-4", "aria-hidden": "true" })
230
+ }
231
+ )
232
+ ] }),
233
+ /* @__PURE__ */ jsx("p", { id: hintId, className: "eb:min-h-4 eb:text-[11.5px] eb:text-foreground-muted", children: unusual ? /* @__PURE__ */ jsx("span", { className: "eb:text-warning", children: t("{provider} keys usually start with {prefix}.", { provider: name, prefix: keyHint }) }) : keyHint ? t("{provider} keys start with {prefix}.", { provider: name, prefix: keyHint }) : null })
234
+ ] })
235
+ ] }),
236
+ /* @__PURE__ */ jsxs("div", { className: "eb:flex eb:items-start eb:gap-2 eb:border-t eb:border-border eb:bg-surface-75 eb:px-6 eb:py-3 eb:text-[11.5px] eb:leading-relaxed eb:text-foreground-lighter", children: [
237
+ /* @__PURE__ */ jsx(ShieldCheck, { className: "eb:mt-0.5 eb:size-3.5 eb:shrink-0 eb:text-foreground-muted", "aria-hidden": "true" }),
238
+ /* @__PURE__ */ jsx("span", { children: t("The key stays in this browser and is sent only to {provider}.", { provider: name }) })
239
+ ] }),
240
+ /* @__PURE__ */ jsxs(DialogFooter, { className: "eb:border-t eb:border-border eb:px-6 eb:py-4", children: [
241
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", onClick: () => aiKey.answer(null), children: t("Cancel") }),
242
+ /* @__PURE__ */ jsx(Button, { type: "submit", disabled: !key, "data-testid": "eb-ai-connect-submit", children: t("Connect") })
243
+ ] })
244
+ ] })
245
+ }
246
+ ) });
247
+ }
248
+
249
+ // src/ai/GenerateButton.jsx
250
+ import { useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState2 } from "react";
251
+ import { Check, Loader2, Search, Sparkles, TriangleAlert as TriangleAlert2 } from "lucide-react";
252
+
253
+ // src/ai/chip.js
254
+ var chipClassName = (selected = false) => cn(
255
+ "eb:inline-flex eb:h-7 eb:items-center eb:gap-1 eb:rounded-full eb:border eb:px-3 eb:text-[12px] eb:font-medium eb:transition-colors eb:outline-none eb:focus-visible:ring-[3px] eb:focus-visible:ring-ring/50 eb:disabled:opacity-50",
256
+ selected ? "eb:border-primary eb:bg-primary eb:text-primary-foreground" : "eb:border-border eb:bg-background eb:text-foreground eb:hover:border-primary/50 eb:hover:bg-primary/5"
257
+ );
258
+
259
+ // src/ai/GenerateButton.jsx
260
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
261
+ var fold = (text) => text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
262
+ function GenerateButton({ context, generate: generateEmail }) {
263
+ const { editor, ready, isDirty, markDirty, template, setMeta, variables, sender, brand } = context;
264
+ const { t, locale, notify } = useUi();
265
+ const [open, setOpen] = useState2(false);
266
+ const [purpose, setPurpose] = useState2(
267
+ /** @type {string|null} */
268
+ null
269
+ );
270
+ const [details, setDetails] = useState2("");
271
+ const [query, setQuery] = useState2("");
272
+ const [busy, setBusy] = useState2(false);
273
+ const [error, setError] = useState2("");
274
+ const [seconds, setSeconds] = useState2(0);
275
+ const [dirty, setDirty] = useState2(false);
276
+ const abortRef = useRef2(
277
+ /** @type {AbortController|null} */
278
+ null
279
+ );
280
+ const searchRef = useRef2(
281
+ /** @type {HTMLInputElement|null} */
282
+ null
283
+ );
284
+ useSearchShortcut(searchRef);
285
+ useEffect2(() => () => abortRef.current?.abort(), []);
286
+ useEffect2(() => {
287
+ if (!busy) return void 0;
288
+ setSeconds(0);
289
+ const timer = setInterval(() => setSeconds((s) => s + 1), 1e3);
290
+ return () => clearInterval(timer);
291
+ }, [busy]);
292
+ const groups = useMemo(() => {
293
+ const words = fold(query.trim()).split(/\s+/).filter(Boolean);
294
+ return purposesByCategory().map((group) => ({
295
+ ...group,
296
+ purposes: group.purposes.filter((entry) => {
297
+ const label = fold(`${t(entry.label)} ${entry.label} ${t(group.label)}`);
298
+ return words.every((word) => label.includes(word));
299
+ })
300
+ })).filter((group) => group.purposes.length > 0);
301
+ }, [query, t]);
302
+ const openDialog = () => {
303
+ setError("");
304
+ setDirty(isDirty());
305
+ setOpen(true);
306
+ };
307
+ const close = (next) => {
308
+ if (next) return;
309
+ abortRef.current?.abort();
310
+ setOpen(false);
311
+ };
312
+ const generate = async () => {
313
+ if (busy || !purpose && !details.trim()) return;
314
+ setError("");
315
+ setBusy(true);
316
+ const controller = new AbortController();
317
+ abortRef.current = controller;
318
+ try {
319
+ const email = await generateEmail(
320
+ {
321
+ purpose,
322
+ details: details.trim(),
323
+ language: String(locale || "en").slice(0, 2),
324
+ sender: sender || {},
325
+ ...hasBrand(brand) ? { brand: { primary: brand.primary, heading: brand.heading, font: brand.font } } : {},
326
+ variables: (variables || []).map((variable) => ({ key: variable.key, label: variable.label }))
327
+ },
328
+ { signal: controller.signal }
329
+ );
330
+ if (controller.signal.aborted) return;
331
+ editor.loadMjml(hasBrand(brand) ? brandTemplate(email.mjml, brand, { sender, t }) : email.mjml);
332
+ setMeta((meta) => ({
333
+ ...meta,
334
+ name: meta.name || email.name,
335
+ subject: email.subject || meta.subject,
336
+ preheader: email.preheader || meta.preheader
337
+ }));
338
+ if (template.id) markDirty();
339
+ setOpen(false);
340
+ notify("success", t("Your email is ready. Edit anything, then save."));
341
+ } catch (err) {
342
+ if (!controller.signal.aborted) setError(err?.message ? t(err.message) : t("The email could not be generated."));
343
+ } finally {
344
+ if (abortRef.current === controller) abortRef.current = null;
345
+ setBusy(false);
346
+ }
347
+ };
348
+ const cancel = () => {
349
+ abortRef.current?.abort();
350
+ setBusy(false);
351
+ };
352
+ const canGenerate = Boolean(purpose || details.trim());
353
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
354
+ /* @__PURE__ */ jsxs2(Button, { variant: "outline", size: "sm-compact", disabled: !ready, onClick: openDialog, children: [
355
+ /* @__PURE__ */ jsx2(Sparkles, {}),
356
+ t("Generate")
357
+ ] }),
358
+ /* @__PURE__ */ jsx2(Dialog, { open, onOpenChange: close, children: /* @__PURE__ */ jsxs2(
359
+ DialogContent,
360
+ {
361
+ className: "eb:flex eb:max-h-[calc(100dvh-2rem)] eb:flex-col eb:gap-0 eb:p-0 eb:sm:max-w-2xl",
362
+ "data-testid": "eb-generate",
363
+ onOpenAutoFocus: (event) => {
364
+ event.preventDefault();
365
+ searchRef.current?.focus();
366
+ },
367
+ children: [
368
+ /* @__PURE__ */ jsxs2(DialogHeader, { className: "eb:gap-1.5 eb:px-6 eb:pt-6 eb:pb-3", children: [
369
+ /* @__PURE__ */ jsx2(DialogTitle, { children: t("Generate an email") }),
370
+ /* @__PURE__ */ jsx2(DialogDescription, { children: t("Pick what it is for. AI writes a complete email that opens here, ready to edit.") })
371
+ ] }),
372
+ /* @__PURE__ */ jsx2("div", { className: "eb:px-6 eb:pb-3", children: /* @__PURE__ */ jsxs2("div", { className: "eb:relative", children: [
373
+ /* @__PURE__ */ jsx2(Search, { className: "eb:pointer-events-none eb:absolute eb:top-1/2 eb:left-2.5 eb:size-3.5 eb:-translate-y-1/2 eb:text-foreground-muted", "aria-hidden": "true" }),
374
+ /* @__PURE__ */ jsx2(
375
+ Input,
376
+ {
377
+ ref: searchRef,
378
+ type: "search",
379
+ value: query,
380
+ onChange: (event) => setQuery(event.target.value),
381
+ placeholder: t("Search: sale, password, birthday\u2026"),
382
+ "aria-label": t("Search"),
383
+ "aria-keyshortcuts": SEARCH_KEYS,
384
+ className: "eb:pr-16 eb:pl-8 eb:[&::-webkit-search-cancel-button]:hidden",
385
+ disabled: busy
386
+ }
387
+ ),
388
+ /* @__PURE__ */ jsx2(SearchKeyHint, { className: "eb:absolute eb:top-1/2 eb:right-2 eb:-translate-y-1/2" })
389
+ ] }) }),
390
+ /* @__PURE__ */ jsx2("div", { className: "eb:min-h-0 eb:flex-1 eb:overflow-y-auto eb:border-y eb:border-border eb:px-6 eb:py-4", "data-testid": "eb-generate-purposes", children: /* @__PURE__ */ jsxs2("fieldset", { disabled: busy, className: "eb:m-0 eb:min-w-0 eb:border-0 eb:p-0", children: [
391
+ /* @__PURE__ */ jsx2("legend", { className: "eb:sr-only", children: t("What is the email for?") }),
392
+ groups.length === 0 && /* @__PURE__ */ jsx2("p", { className: "eb:py-6 eb:text-center eb:text-[13px] eb:text-foreground-muted", children: t("Nothing matches. Describe your email in the details below.") }),
393
+ /* @__PURE__ */ jsx2("div", { className: "eb:flex eb:flex-col eb:gap-4", children: groups.map((group) => /* @__PURE__ */ jsxs2("section", { className: "eb:flex eb:flex-col eb:gap-2", "aria-label": t(group.label), children: [
394
+ /* @__PURE__ */ jsx2(SectionLabel, { children: t(group.label) }),
395
+ /* @__PURE__ */ jsx2("div", { className: "eb:flex eb:flex-wrap eb:gap-1.5", children: group.purposes.map((entry) => {
396
+ const selected = purpose === entry.id;
397
+ return /* @__PURE__ */ jsxs2(
398
+ "button",
399
+ {
400
+ type: "button",
401
+ "aria-pressed": selected,
402
+ "data-purpose": entry.id,
403
+ onClick: () => setPurpose(selected ? null : entry.id),
404
+ className: chipClassName(selected),
405
+ children: [
406
+ selected && /* @__PURE__ */ jsx2(Check, { className: "eb:size-3", "aria-hidden": "true" }),
407
+ t(entry.label)
408
+ ]
409
+ },
410
+ entry.id
411
+ );
412
+ }) })
413
+ ] }, group.id)) })
414
+ ] }) }),
415
+ /* @__PURE__ */ jsxs2("div", { className: "eb:flex eb:flex-col eb:gap-3 eb:px-6 eb:pt-3 eb:pb-6", children: [
416
+ /* @__PURE__ */ jsx2(
417
+ Textarea,
418
+ {
419
+ value: details,
420
+ onChange: (event) => setDetails(event.target.value.slice(0, MAX_DETAILS_LENGTH)),
421
+ placeholder: t("Details (optional): product, dates, offer, tone, language\u2026"),
422
+ "aria-label": t("Details (optional)"),
423
+ rows: 2,
424
+ className: "eb:min-h-14",
425
+ disabled: busy,
426
+ onKeyDown: (event) => {
427
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) generate();
428
+ }
429
+ }
430
+ ),
431
+ error ? /* @__PURE__ */ jsxs2("p", { role: "alert", className: "eb:flex eb:items-start eb:gap-1.5 eb:text-[12px] eb:text-destructive", children: [
432
+ /* @__PURE__ */ jsx2(TriangleAlert2, { className: "eb:mt-px eb:size-3.5 eb:shrink-0", "aria-hidden": "true" }),
433
+ error
434
+ ] }) : dirty && !busy && /* @__PURE__ */ jsx2("p", { className: "eb:text-[12px] eb:text-foreground-muted", children: t("The email being edited will be replaced, unsaved changes included.") }),
435
+ /* @__PURE__ */ jsxs2("div", { className: "eb:flex eb:items-center eb:justify-end eb:gap-2", children: [
436
+ busy && /* @__PURE__ */ jsx2("p", { className: "eb:mr-auto eb:text-[12px] eb:text-foreground-muted", role: "status", "aria-live": "polite", children: t("Writing your email\u2026 {seconds} s", { seconds }) }),
437
+ busy && /* @__PURE__ */ jsx2(Button, { variant: "outline", size: "sm", onClick: cancel, children: t("Cancel") }),
438
+ /* @__PURE__ */ jsxs2(Button, { size: "sm", onClick: generate, disabled: !canGenerate || busy, "data-testid": "eb-generate-submit", children: [
439
+ busy ? /* @__PURE__ */ jsx2(Loader2, { className: "eb:animate-spin" }) : /* @__PURE__ */ jsx2(Sparkles, {}),
440
+ t("Generate")
441
+ ] })
442
+ ] })
443
+ ] })
444
+ ]
445
+ }
446
+ ) })
447
+ ] });
448
+ }
449
+
450
+ // src/ai/RewriteText.jsx
451
+ import { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
452
+ import { ChevronDown, Languages, Loader2 as Loader22, Sparkles as Sparkles2, TriangleAlert as TriangleAlert3 } from "lucide-react";
453
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
454
+ function nativeName(code) {
455
+ try {
456
+ const name = new Intl.DisplayNames([code], { type: "language" }).of(code) || code;
457
+ return name.charAt(0).toLocaleUpperCase(code) + name.slice(1);
458
+ } catch {
459
+ return code;
460
+ }
461
+ }
462
+ function RewriteText({ editor, selection, rewrite }) {
463
+ const { t, notify } = useUi();
464
+ const [running, setRunning] = useState3(
465
+ /** @type {string|null} */
466
+ null
467
+ );
468
+ const [error, setError] = useState3("");
469
+ const [instruction, setInstruction] = useState3("");
470
+ const abortRef = useRef3(
471
+ /** @type {AbortController|null} */
472
+ null
473
+ );
474
+ const label = selection.tagName === "mj-button";
475
+ useEffect3(() => () => abortRef.current?.abort(), []);
476
+ const currentText = () => {
477
+ const element = editor.getElement(selection.id);
478
+ return (label ? element?.content : element?.html) ?? "";
479
+ };
480
+ const run = async (action, extra = {}) => {
481
+ if (running) return;
482
+ const text = currentText();
483
+ setError("");
484
+ setRunning(action);
485
+ const controller = new AbortController();
486
+ abortRef.current = controller;
487
+ try {
488
+ const result = await rewrite({ action, text, format: label ? "plain" : "html", ...extra }, { signal: controller.signal });
489
+ if (controller.signal.aborted) return;
490
+ if (currentText() !== text) {
491
+ setError(t("The text changed in the meantime, so it was left as it is."));
492
+ return;
493
+ }
494
+ editor.setContent(label ? result.text : cleanRichText(result.text, document), selection.id);
495
+ if (action === "custom") setInstruction("");
496
+ notify("success", t("Text rewritten. Ctrl+Z brings back the previous one."));
497
+ } catch (err) {
498
+ if (!controller.signal.aborted) setError(err?.message ? t(err.message) : t("The text could not be rewritten."));
499
+ } finally {
500
+ if (abortRef.current === controller) abortRef.current = null;
501
+ setRunning(null);
502
+ }
503
+ };
504
+ const cancel = () => {
505
+ abortRef.current?.abort();
506
+ setRunning(null);
507
+ };
508
+ const busy = Boolean(running);
509
+ const spinner = /* @__PURE__ */ jsx3(Loader22, { className: "eb:size-3 eb:animate-spin", "aria-hidden": "true" });
510
+ return /* @__PURE__ */ jsxs3("section", { className: "eb:space-y-2.5", "aria-label": t("Rewrite with AI"), "data-testid": "eb-rewrite", children: [
511
+ /* @__PURE__ */ jsxs3(SectionLabel, { className: "eb:flex eb:items-center eb:gap-1.5", children: [
512
+ /* @__PURE__ */ jsx3(Sparkles2, { className: "eb:size-3", "aria-hidden": "true" }),
513
+ t("Rewrite with AI")
514
+ ] }),
515
+ /* @__PURE__ */ jsxs3("div", { className: "eb:flex eb:flex-wrap eb:gap-1.5", children: [
516
+ REWRITE_ACTIONS.map((action) => /* @__PURE__ */ jsxs3("button", { type: "button", className: chipClassName(), disabled: busy, onClick: () => run(action.id), children: [
517
+ running === action.id && spinner,
518
+ t(action.label)
519
+ ] }, action.id)),
520
+ /* @__PURE__ */ jsxs3(DropdownMenu, { children: [
521
+ /* @__PURE__ */ jsx3(DropdownMenuTrigger, { asChild: true, disabled: busy, children: /* @__PURE__ */ jsxs3("button", { type: "button", className: chipClassName(), children: [
522
+ running === "translate" ? spinner : /* @__PURE__ */ jsx3(Languages, { className: "eb:size-3", "aria-hidden": "true" }),
523
+ t("Translate"),
524
+ /* @__PURE__ */ jsx3(ChevronDown, { className: "eb:size-3", "aria-hidden": "true" })
525
+ ] }) }),
526
+ /* @__PURE__ */ jsx3(DropdownMenuContent, { align: "start", "aria-label": t("Translate into"), children: REWRITE_LANGUAGES.map((code) => /* @__PURE__ */ jsx3(DropdownMenuItem, { lang: code, onSelect: () => run("translate", { targetLanguage: code }), children: nativeName(code) }, code)) })
527
+ ] })
528
+ ] }),
529
+ /* @__PURE__ */ jsxs3(
530
+ "form",
531
+ {
532
+ className: "eb:flex eb:gap-1.5",
533
+ onSubmit: (event) => {
534
+ event.preventDefault();
535
+ if (instruction.trim()) run("custom", { instruction: instruction.trim() });
536
+ },
537
+ children: [
538
+ /* @__PURE__ */ jsx3(
539
+ Input,
540
+ {
541
+ value: instruction,
542
+ onChange: (event) => setInstruction(event.target.value),
543
+ maxLength: MAX_REWRITE_INSTRUCTION,
544
+ placeholder: t("Or say what to change\u2026"),
545
+ "aria-label": t("What to change"),
546
+ disabled: busy
547
+ }
548
+ ),
549
+ /* @__PURE__ */ jsx3(Button, { type: "submit", variant: "outline", size: "icon-sm", "aria-label": t("Rewrite"), disabled: busy || !instruction.trim(), children: running === "custom" ? /* @__PURE__ */ jsx3(Loader22, { className: "eb:animate-spin" }) : /* @__PURE__ */ jsx3(Sparkles2, {}) })
550
+ ]
551
+ }
552
+ ),
553
+ running && /* @__PURE__ */ jsxs3("p", { className: "eb:flex eb:items-center eb:gap-2 eb:text-[12px] eb:text-foreground-muted", role: "status", "aria-live": "polite", children: [
554
+ t("Rewriting\u2026"),
555
+ /* @__PURE__ */ jsx3(Button, { type: "button", variant: "link", size: "xs", className: "eb:h-auto eb:px-0", onClick: cancel, children: t("Cancel") })
556
+ ] }),
557
+ error && /* @__PURE__ */ jsxs3("p", { role: "alert", className: "eb:flex eb:items-start eb:gap-1.5 eb:text-[12px] eb:text-destructive", children: [
558
+ /* @__PURE__ */ jsx3(TriangleAlert3, { className: "eb:mt-px eb:size-3.5 eb:shrink-0", "aria-hidden": "true" }),
559
+ error
560
+ ] })
561
+ ] });
562
+ }
563
+
564
+ // src/ai/SubjectIdeas.jsx
565
+ import { useEffect as useEffect4, useLayoutEffect, useRef as useRef4, useState as useState4 } from "react";
566
+ import { Check as Check2, Loader2 as Loader23, Sparkles as Sparkles3, TriangleAlert as TriangleAlert4 } from "lucide-react";
567
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
568
+ var MAX_AVOID = 12;
569
+ function SubjectIdeas({ editor, meta, onSubject, suggest, variables = null }) {
570
+ const { t, locale } = useUi();
571
+ const [running, setRunning] = useState4(false);
572
+ const [error, setError] = useState4("");
573
+ const [ideas, setIdeas] = useState4(
574
+ /** @type {string[]|null} */
575
+ null
576
+ );
577
+ const [original, setOriginal] = useState4("");
578
+ const shownRef = useRef4(
579
+ /** @type {string[]} */
580
+ []
581
+ );
582
+ const abortRef = useRef4(
583
+ /** @type {AbortController|null} */
584
+ null
585
+ );
586
+ const subjectRef = useRef4(meta.subject);
587
+ subjectRef.current = meta.subject;
588
+ const rootRef = useRef4(
589
+ /** @type {HTMLDivElement|null} */
590
+ null
591
+ );
592
+ const focusRef = useRef4(
593
+ /** @type {'list'|'button'|null} */
594
+ null
595
+ );
596
+ useEffect4(() => () => abortRef.current?.abort(), []);
597
+ useEffect4(
598
+ () => editor.on("load", () => {
599
+ abortRef.current?.abort();
600
+ abortRef.current = null;
601
+ shownRef.current = [];
602
+ setRunning(false);
603
+ setIdeas(null);
604
+ setError("");
605
+ setOriginal("");
606
+ }),
607
+ [editor]
608
+ );
609
+ const typed = meta.subject.trim();
610
+ useEffect4(() => {
611
+ if (ideas && typed && !ideas.includes(meta.subject) && typed !== original) setOriginal(typed);
612
+ }, [ideas, meta.subject, typed, original]);
613
+ useLayoutEffect(() => {
614
+ const target = focusRef.current;
615
+ if (!target || running) return;
616
+ focusRef.current = null;
617
+ const root = rootRef.current;
618
+ const element = target === "list" ? root?.querySelector('[role="radio"][tabindex="0"]') : root?.querySelector("[data-subject-ideas-button]");
619
+ element?.focus();
620
+ });
621
+ const ready = editor.status === "ready";
622
+ const run = async () => {
623
+ if (running || editor.status !== "ready") return;
624
+ const first = !ideas;
625
+ const yours = first ? subjectRef.current.trim() : original;
626
+ const controller = new AbortController();
627
+ abortRef.current = controller;
628
+ setError("");
629
+ setRunning(true);
630
+ try {
631
+ const result = await suggest(
632
+ {
633
+ subject: yours,
634
+ preheader: meta.preheader,
635
+ text: emailWords(editor.exportMjml()),
636
+ language: locale,
637
+ variables: variables?.map(({ key, label, kind }) => ({ key, label, kind })),
638
+ avoid: shownRef.current.slice(-MAX_AVOID)
639
+ },
640
+ { signal: controller.signal }
641
+ );
642
+ if (controller.signal.aborted) return;
643
+ const current = subjectRef.current;
644
+ const kept = ideas?.includes(current) ? [current] : [];
645
+ const next = [...kept, ...result.subjects.filter((subject) => subject !== current)];
646
+ shownRef.current = [...shownRef.current, ...result.subjects];
647
+ setOriginal(current.trim() && !ideas?.includes(current) ? current.trim() : yours);
648
+ setIdeas(next);
649
+ if (first) focusRef.current = "list";
650
+ } catch (err) {
651
+ if (!controller.signal.aborted) setError(err?.message ? t(err.message) : t("No subject could be suggested."));
652
+ } finally {
653
+ if (abortRef.current === controller) {
654
+ abortRef.current = null;
655
+ setRunning(false);
656
+ }
657
+ }
658
+ };
659
+ const cancel = () => {
660
+ abortRef.current?.abort();
661
+ abortRef.current = null;
662
+ setRunning(false);
663
+ focusRef.current = "button";
664
+ };
665
+ const done = () => {
666
+ setIdeas(null);
667
+ setError("");
668
+ shownRef.current = [];
669
+ focusRef.current = "button";
670
+ };
671
+ const options = ideas ? [...original ? [{ text: original, yours: true }] : [], ...ideas.map((text) => ({ text, yours: false }))] : [];
672
+ const checked = options.findIndex((option) => option.text === meta.subject);
673
+ const tabStop = checked === -1 ? 0 : checked;
674
+ const onKeyDown = (event) => {
675
+ const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
676
+ const to = step ? (tabStop + step + options.length) % options.length : event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 : -1;
677
+ if (to === -1 || !options.length) return;
678
+ event.preventDefault();
679
+ onSubject(options[to].text);
680
+ rootRef.current?.querySelectorAll('[role="radio"]')[to]?.focus();
681
+ };
682
+ const spinner = /* @__PURE__ */ jsx4(Loader23, { className: "eb:size-3 eb:animate-spin", "aria-hidden": "true" });
683
+ return /* @__PURE__ */ jsxs4("div", { className: "eb:space-y-2", "data-testid": "eb-subject-ideas", ref: rootRef, children: [
684
+ !ideas && /* @__PURE__ */ jsxs4("button", { type: "button", className: chipClassName(), disabled: running || !ready, onClick: run, "data-subject-ideas-button": true, children: [
685
+ running ? spinner : /* @__PURE__ */ jsx4(Sparkles3, { className: "eb:size-3", "aria-hidden": "true" }),
686
+ t("Suggest subjects")
687
+ ] }),
688
+ ideas && /* @__PURE__ */ jsxs4("div", { className: "eb:space-y-2 eb:rounded-md eb:border eb:border-border eb:p-1.5", children: [
689
+ /* @__PURE__ */ jsx4("div", { role: "radiogroup", "aria-label": t("Subject ideas"), className: "eb:space-y-0.5", onKeyDown, children: options.map((option, index) => {
690
+ const chosen = index === checked;
691
+ return /* @__PURE__ */ jsxs4(
692
+ "button",
693
+ {
694
+ type: "button",
695
+ role: "radio",
696
+ "aria-checked": chosen,
697
+ tabIndex: index === tabStop ? 0 : -1,
698
+ className: cn(
699
+ "eb:flex eb:w-full eb:items-start eb:gap-2 eb:rounded eb:px-2 eb:py-1.5 eb:text-left eb:text-[12.5px] eb:leading-snug eb:transition-colors eb:outline-none eb:focus-visible:ring-[3px] eb:focus-visible:ring-ring/50",
700
+ chosen ? "eb:bg-primary/10 eb:text-foreground" : "eb:text-foreground-lighter eb:hover:bg-muted"
701
+ ),
702
+ onClick: () => onSubject(option.text),
703
+ children: [
704
+ /* @__PURE__ */ jsx4(Check2, { className: cn("eb:mt-0.5 eb:size-3.5 eb:shrink-0", chosen ? "eb:text-primary" : "eb:invisible"), "aria-hidden": "true" }),
705
+ /* @__PURE__ */ jsx4("span", { className: "eb:min-w-0 eb:flex-1 eb:break-words", children: option.text }),
706
+ option.yours && /* @__PURE__ */ jsx4("span", { className: "eb:shrink-0 eb:text-[11px] eb:text-foreground-muted", children: t("yours") })
707
+ ]
708
+ },
709
+ `${option.yours ? "yours" : "idea"}:${option.text}`
710
+ );
711
+ }) }),
712
+ /* @__PURE__ */ jsxs4("div", { className: "eb:flex eb:items-center eb:gap-1.5 eb:px-1 eb:pb-0.5", children: [
713
+ /* @__PURE__ */ jsxs4("button", { type: "button", className: chipClassName(), disabled: running || !ready, onClick: run, "data-subject-ideas-button": true, children: [
714
+ running ? spinner : /* @__PURE__ */ jsx4(Sparkles3, { className: "eb:size-3", "aria-hidden": "true" }),
715
+ t("More ideas")
716
+ ] }),
717
+ /* @__PURE__ */ jsx4(Button, { type: "button", variant: "ghost", size: "xs", className: "eb:ml-auto", disabled: running, onClick: done, children: t("Done") })
718
+ ] })
719
+ ] }),
720
+ running && /* @__PURE__ */ jsxs4("p", { className: "eb:flex eb:items-center eb:gap-2 eb:text-[12px] eb:text-foreground-muted", role: "status", "aria-live": "polite", children: [
721
+ t("Reading the email\u2026"),
722
+ /* @__PURE__ */ jsx4(Button, { type: "button", variant: "link", size: "xs", className: "eb:h-auto eb:px-0", onClick: cancel, children: t("Cancel") })
723
+ ] }),
724
+ error && /* @__PURE__ */ jsxs4("p", { role: "alert", className: "eb:flex eb:items-start eb:gap-1.5 eb:text-[12px] eb:text-destructive", children: [
725
+ /* @__PURE__ */ jsx4(TriangleAlert4, { className: "eb:mt-px eb:size-3.5 eb:shrink-0", "aria-hidden": "true" }),
726
+ error
727
+ ] })
728
+ ] });
729
+ }
730
+
731
+ // src/ai/createAi.js
732
+ var PARTS = (
733
+ /** @type {const} */
734
+ ["generate", "rewrite", "suggestSubjects"]
735
+ );
736
+ var ROUTES = { generate: "/generate", rewrite: "/rewrite", suggestSubjects: "/subjects" };
737
+ function createAi(options = {}) {
738
+ if (!options || typeof options !== "object") throw new TypeError("createAi: options must be an object");
739
+ const { model, maxAttempts, compile, baseUrl, headers, fetch: fetchImpl, provider: providerOption, apiKey, askKey = false } = options;
740
+ const usesProvider = providerOption !== void 0;
741
+ const http = baseUrl !== void 0 || headers !== void 0 || fetchImpl !== void 0 && !usesProvider;
742
+ if (model !== void 0 && typeof model !== "function") throw new TypeError("createAi: `model` must be a function");
743
+ if (model && http) throw new TypeError("createAi: pass `model` (the AI runs in the page) or `baseUrl` (on the server), not both");
744
+ if (usesProvider && (model || http)) {
745
+ throw new TypeError("createAi: `provider` runs the AI in the page \u2014 not with `model`, `baseUrl` or `headers` (give the provider `{ id, model, baseUrl, headers }`)");
746
+ }
747
+ if (!usesProvider && (apiKey !== void 0 || options.askKey !== void 0)) {
748
+ throw new TypeError('createAi: `apiKey` and `askKey` go with a `provider` (e.g. provider: "openrouter")');
749
+ }
750
+ if (apiKey !== void 0 && (typeof apiKey !== "string" || !apiKey)) throw new TypeError("createAi: `apiKey` must be a non-empty string");
751
+ if (compile !== void 0 && (typeof compile !== "function" || !model && !usesProvider)) {
752
+ throw new TypeError("createAi: `compile` must be a function, and goes with `provider` or `model` (the AI in the page)");
753
+ }
754
+ if (typeof askKey !== "boolean") throw new TypeError("createAi: `askKey` must be true or false");
755
+ if (apiKey && askKey) throw new TypeError("createAi: `apiKey` (one key for all) or `askKey` (each user's own), not both");
756
+ const provider = usesProvider ? resolveProvider(providerOption) : null;
757
+ if (provider && !apiKey && !askKey && !provider.keyless) {
758
+ throw new TypeError(`createAi: ${provider.name} needs a key \u2014 \`apiKey\`, or \`askKey: true\` to let each user connect their own`);
759
+ }
760
+ for (const part of PARTS) {
761
+ const value = options[part];
762
+ if (value !== void 0 && typeof value !== "boolean" && typeof value !== "function") {
763
+ throw new TypeError(`createAi: \`${part}\` must be a function, true or false`);
764
+ }
765
+ }
766
+ const ownOnly = !model && !http && PARTS.some((part) => typeof options[part] === "function");
767
+ let transport = {};
768
+ const key = provider && askKey ? createKeyStore(provider) : null;
769
+ if (provider) {
770
+ const generator = createEmailGenerator({
771
+ model: providerEmailModel({
772
+ provider,
773
+ fetch: fetchImpl,
774
+ // The user's key, asked when there is none ('' when they cancel: KEY_MISSING).
775
+ apiKey: key ? async () => key.get() || await key.request() : apiKey
776
+ }),
777
+ maxAttempts,
778
+ compile
779
+ });
780
+ for (const part of PARTS) transport[part] = key ? askedAgain(generator[part], key) : generator[part];
781
+ } else if (model) {
782
+ transport = createEmailGenerator({ model, maxAttempts, compile });
783
+ } else if (!ownOnly) {
784
+ const { call } = createHttpCaller({ baseUrl, headers, fetch: fetchImpl });
785
+ for (const part of PARTS) {
786
+ transport[part] = (input, { signal } = {}) => call(ROUTES[part], { method: "POST", body: JSON.stringify(input), signal });
787
+ }
788
+ }
789
+ const resolve = (part) => {
790
+ const value = options[part];
791
+ if (typeof value === "function") return value;
792
+ if (value === false) return null;
793
+ return transport[part] || null;
794
+ };
795
+ return Object.freeze({
796
+ generate: (
797
+ /** @type {GenerateFn|null} */
798
+ resolve("generate")
799
+ ),
800
+ rewrite: (
801
+ /** @type {RewriteFn|null} */
802
+ resolve("rewrite")
803
+ ),
804
+ suggestSubjects: (
805
+ /** @type {SuggestSubjectsFn|null} */
806
+ resolve("suggestSubjects")
807
+ ),
808
+ key,
809
+ messages: AI_MESSAGES,
810
+ components: Object.freeze({ Generate: GenerateButton, Rewrite: RewriteText, Subjects: SubjectIdeas, Connect: key ? ConnectAi : null })
811
+ });
812
+ }
813
+ function askedAgain(run, key) {
814
+ return async (input, options) => {
815
+ try {
816
+ return await run(input, options);
817
+ } catch (error) {
818
+ if (
819
+ /** @type {any} */
820
+ error?.code !== "KEY_REFUSED" || options?.signal?.aborted
821
+ ) throw error;
822
+ key.refuse();
823
+ try {
824
+ return await run(input, options);
825
+ } catch (again) {
826
+ if (
827
+ /** @type {any} */
828
+ again?.code === "KEY_REFUSED"
829
+ ) key.refuse();
830
+ throw again;
831
+ }
832
+ }
833
+ };
834
+ }
835
+ export {
836
+ AI_MESSAGES,
837
+ AI_PROVIDERS,
838
+ DEFAULT_CLAUDE_MODEL,
839
+ EMAIL_PURPOSES,
840
+ EmailGenerationError,
841
+ GENERATION_ERRORS,
842
+ PURPOSE_CATEGORIES,
843
+ REWRITE_ACTIONS,
844
+ REWRITE_LANGUAGES,
845
+ claudeEmailModel,
846
+ createAi,
847
+ createEmailGenerator,
848
+ emailWords,
849
+ providerEmailModel,
850
+ resolveProvider
851
+ };
852
+ //# sourceMappingURL=ai.js.map