runline 0.15.1 → 0.16.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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Render agent-authored React Email JSX into wire-ready email HTML
3
+ * plus a plain-text alternative.
4
+ *
5
+ * Why JSX instead of hand-written HTML: email-grade HTML (table
6
+ * layouts, inlined styles, client quirks) is exactly what agents get
7
+ * wrong; @react-email/components encodes that knowledge, and agents
8
+ * write JSX fluently. Evaluating agent-supplied JSX adds no new risk
9
+ * class — runline's executor is already a coding surface.
10
+ *
11
+ * All heavy dependencies (react, sucrase, react-email) are optional
12
+ * peers of runline — like playwright for browser actions — loaded
13
+ * lazily on first use. Hosts that never render JSX pay nothing;
14
+ * hosts without the packages get an install hint instead of a
15
+ * module-resolution stack trace.
16
+ */
17
+ const PEER_INSTALL_HINT = "jsx email bodies require optional peer packages. Install them in the host project: " +
18
+ "react react-dom @react-email/components @react-email/render sucrase. " +
19
+ "Alternatively pass `html` or `text` instead of `jsx`.";
20
+ let modulesPromise;
21
+ async function loadModules() {
22
+ modulesPromise ??= Promise.all([
23
+ import("react"),
24
+ import("@react-email/components"),
25
+ import("@react-email/render"),
26
+ import("sucrase"),
27
+ ]).then(([react, components, renderMod, sucrase]) => ({
28
+ React: react.default ?? react,
29
+ components: components,
30
+ render: renderMod.render,
31
+ transform: sucrase.transform,
32
+ }), (err) => {
33
+ // Don't cache the failure — the host can install the peers and
34
+ // retry without restarting the process.
35
+ modulesPromise = undefined;
36
+ const msg = err instanceof Error ? err.message : String(err);
37
+ throw new Error(`${PEER_INSTALL_HINT} (${msg})`);
38
+ });
39
+ return modulesPromise;
40
+ }
41
+ /**
42
+ * Components become named parameters of the evaluation function, so
43
+ * only exports that are valid JS identifiers (the React components —
44
+ * capitalized) can be exposed. Lowercase utility exports are omitted
45
+ * on purpose: the JSX surface is components-only.
46
+ */
47
+ function componentScope(components) {
48
+ const names = [];
49
+ const values = [];
50
+ for (const [key, value] of Object.entries(components)) {
51
+ if (!/^[A-Z][A-Za-z0-9_$]*$/.test(key))
52
+ continue;
53
+ names.push(key);
54
+ values.push(value);
55
+ }
56
+ return { names, values };
57
+ }
58
+ export async function renderEmailJsx(jsx) {
59
+ const source = jsx.trim();
60
+ if (!source)
61
+ throw new Error("jsx body is empty");
62
+ const { React, components, render, transform } = await loadModules();
63
+ let compiled;
64
+ try {
65
+ // Wrap as an expression statement so a bare `<Html>…</Html>` is a
66
+ // valid program for sucrase. The parentheses nudge the surface
67
+ // toward a single expression (statement forms like `const x = 1`
68
+ // fail to parse) — a shape hint, not a boundary: the executor is
69
+ // already a coding surface, so escaping it grants nothing.
70
+ compiled = transform(`__element = (${source});`, {
71
+ transforms: ["jsx"],
72
+ production: true,
73
+ }).code;
74
+ }
75
+ catch (err) {
76
+ const msg = err instanceof Error ? err.message : String(err);
77
+ throw new Error(`jsx parse failed: ${msg}`);
78
+ }
79
+ const { names, values } = componentScope(components);
80
+ let element;
81
+ try {
82
+ const fn = new Function("React", ...names, `let __element; ${compiled}; return __element;`);
83
+ element = fn(React, ...values);
84
+ }
85
+ catch (err) {
86
+ const msg = err instanceof Error ? err.message : String(err);
87
+ throw new Error(`jsx evaluation failed: ${msg}. Available components: ${names.join(", ")}`);
88
+ }
89
+ if (!React.isValidElement(element)) {
90
+ throw new Error("jsx must evaluate to a single React element");
91
+ }
92
+ const [html, text] = await Promise.all([
93
+ render(element),
94
+ render(element, { plainText: true }),
95
+ ]);
96
+ return { html, text };
97
+ }
@@ -21,6 +21,7 @@
21
21
  * or parsed `payload` tree for format=full).
22
22
  */
23
23
  import * as t from "typebox";
24
+ import { renderEmailJsx } from "../../_shared/emailJsx.js";
24
25
  import { googleAccessToken } from "../../_shared/googleAuth.js";
25
26
  import { GoogleTimestamp, Id, NonEmptyString, stringEnum, } from "../../_shared/googleSchemas.js";
26
27
  // ─── Auth ────────────────────────────────────────────────────────
@@ -282,6 +283,32 @@ function normalizeAddressList(input) {
282
283
  .map((p) => (p.includes("<") && p.includes(">") ? p : `<${p}>`))
283
284
  .join(", ");
284
285
  }
286
+ // ─── Body resolution ─────────────────────────────────────────────
287
+ /**
288
+ * Resolve the outgoing body for send/reply/draft: `jsx` renders to
289
+ * html with a generated plain-text alternative; an explicit `text`
290
+ * wins over the generated fallback so every jsx email still ships
291
+ * multipart/alternative.
292
+ * Composing inputs carry no `html` field — jsx is the only HTML path
293
+ * (raw HTML embeds go through dangerouslySetInnerHTML inside jsx).
294
+ */
295
+ async function resolveBody(p) {
296
+ let text = p.text;
297
+ let html;
298
+ if (typeof p.jsx === "string" && p.jsx.trim()) {
299
+ let rendered;
300
+ try {
301
+ rendered = await renderEmailJsx(p.jsx);
302
+ }
303
+ catch (err) {
304
+ const msg = err instanceof Error ? err.message : String(err);
305
+ throw new Error(`gmail: ${msg}`);
306
+ }
307
+ html = rendered.html;
308
+ text ??= rendered.text;
309
+ }
310
+ return { text, html };
311
+ }
285
312
  function findHeader(payload, name) {
286
313
  if (!payload?.headers)
287
314
  return undefined;
@@ -444,6 +471,9 @@ async function replyToMessage(ctx, messageId, p) {
444
471
  if (p.replyToSenderOnly && p.replyToRecipientsOnly) {
445
472
  throw new Error("gmail: replyToSenderOnly and replyToRecipientsOnly are mutually exclusive");
446
473
  }
474
+ // Render before any network round-trip so malformed jsx fails
475
+ // without spending Gmail API calls.
476
+ const { text, html } = await resolveBody(p);
447
477
  const original = (await gmailRequest(ctx, "GET", `/messages/${messageId}`, undefined, { format: "metadata", metadataHeaders: REPLY_METADATA_HEADERS }));
448
478
  const subject = findHeader(original.payload, "Subject") ?? "";
449
479
  const messageIdHeader = findHeader(original.payload, "Message-ID") ?? "";
@@ -487,8 +517,8 @@ async function replyToMessage(ctx, messageId, p) {
487
517
  subject: subject.toLowerCase().startsWith("re:")
488
518
  ? subject
489
519
  : `Re: ${subject}`,
490
- text: p.text,
491
- html: p.html,
520
+ text,
521
+ html,
492
522
  inReplyTo: messageIdHeader,
493
523
  references: messageIdHeader,
494
524
  attachments: p.attachments,
@@ -532,6 +562,9 @@ const Attachment = t.Object({
532
562
  contentBase64: AttachmentContent,
533
563
  }, strict);
534
564
  const Attachments = t.Array(Attachment);
565
+ const Jsx = t.Optional(t.String({
566
+ description: 'React Email JSX expression rendered server-side into client-safe HTML with an auto-generated plain-text alternative — handles cross-client rendering (iOS Mail, Gmail, Outlook). For Hebrew/Arabic set dir on the root: `<Html dir="rtl" lang="he"><Container><Heading>שלום</Heading><Text>גוף ההודעה</Text><Button href="...">אישור</Button></Container></Html>`. To embed pre-rendered HTML from elsewhere: `<Html><Body><div dangerouslySetInnerHTML={{ __html: theHtml }} /></Body></Html>`.',
567
+ }));
535
568
  const HasMessageContent = {
536
569
  anyOf: [
537
570
  {
@@ -539,8 +572,8 @@ const HasMessageContent = {
539
572
  properties: { text: { type: "string", minLength: 1, pattern: "\\S" } },
540
573
  },
541
574
  {
542
- required: ["html"],
543
- properties: { html: { type: "string", minLength: 1, pattern: "\\S" } },
575
+ required: ["jsx"],
576
+ properties: { jsx: { type: "string", minLength: 1, pattern: "\\S" } },
544
577
  },
545
578
  {
546
579
  required: ["attachments"],
@@ -550,7 +583,7 @@ const HasMessageContent = {
550
583
  };
551
584
  const ReplyFields = {
552
585
  text: t.Optional(t.String()),
553
- html: t.Optional(t.String()),
586
+ jsx: Jsx,
554
587
  cc: t.Optional(t.String()),
555
588
  bcc: t.Optional(t.String()),
556
589
  replyToSenderOnly: t.Optional(t.Boolean()),
@@ -695,12 +728,12 @@ export default function gmail(rl) {
695
728
  // ── Message ───────────────────────────────────────────
696
729
  rl.registerAction("message.send", {
697
730
  access: "write",
698
- description: "Send an email",
731
+ description: "Send an email. `text` for plain bodies; `jsx` (React Email) for formatted or RTL (Hebrew/Arabic) bodies — renders client-safe HTML with a text fallback.",
699
732
  inputSchema: t.Object({
700
733
  to: NonEmptyString,
701
734
  subject: t.String(),
702
735
  text: t.Optional(t.String({ description: "Plain body" })),
703
- html: t.Optional(t.String({ description: "HTML body" })),
736
+ jsx: Jsx,
704
737
  cc: t.Optional(t.String()),
705
738
  bcc: t.Optional(t.String()),
706
739
  replyTo: t.Optional(t.String()),
@@ -710,6 +743,7 @@ export default function gmail(rl) {
710
743
  }, { ...strict, ...HasMessageContent }),
711
744
  async execute(input, ctx) {
712
745
  const p = (input ?? {});
746
+ const { text, html } = await resolveBody(p);
713
747
  const email = {
714
748
  to: normalizeAddressList(p.to),
715
749
  cc: normalizeAddressList(p.cc),
@@ -717,8 +751,8 @@ export default function gmail(rl) {
717
751
  replyTo: normalizeAddressList(p.replyTo),
718
752
  from: p.from,
719
753
  subject: p.subject ?? "",
720
- text: p.text,
721
- html: p.html,
754
+ text,
755
+ html,
722
756
  attachments: p.attachments,
723
757
  };
724
758
  const body = { raw: encodeEmail(email) };
@@ -729,7 +763,7 @@ export default function gmail(rl) {
729
763
  });
730
764
  rl.registerAction("message.reply", {
731
765
  access: "write",
732
- description: "Reply to a message, preserving threadId and In-Reply-To/References headers",
766
+ description: "Reply to a message, preserving threadId and In-Reply-To/References headers. `text` for plain bodies; `jsx` (React Email) for formatted ones.",
733
767
  inputSchema: t.Object({ messageId: Id, ...ReplyFields }, ReplyOptions),
734
768
  async execute(input, ctx) {
735
769
  const p = (input ?? {});
@@ -943,7 +977,7 @@ export default function gmail(rl) {
943
977
  });
944
978
  rl.registerAction("thread.reply", {
945
979
  access: "write",
946
- description: "Reply to the last message in a thread (convenience wrapper over message.reply)",
980
+ description: "Reply to the last message in a thread (convenience wrapper over message.reply). `text` for plain bodies; `jsx` (React Email) for formatted ones.",
947
981
  inputSchema: t.Object({ id: Id, ...ReplyFields }, ReplyOptions),
948
982
  async execute(input, ctx) {
949
983
  const p = (input ?? {});
@@ -958,12 +992,12 @@ export default function gmail(rl) {
958
992
  // ── Draft ─────────────────────────────────────────────
959
993
  rl.registerAction("draft.create", {
960
994
  access: "write",
961
- description: "Create a draft",
995
+ description: "Create a draft. `text` for plain bodies; `jsx` (React Email) for formatted ones.",
962
996
  inputSchema: t.Object({
963
997
  to: t.Optional(t.String()),
964
998
  subject: t.Optional(t.String()),
965
999
  text: t.Optional(t.String()),
966
- html: t.Optional(t.String()),
1000
+ jsx: Jsx,
967
1001
  cc: t.Optional(t.String()),
968
1002
  bcc: t.Optional(t.String()),
969
1003
  replyTo: t.Optional(t.String()),
@@ -977,6 +1011,7 @@ export default function gmail(rl) {
977
1011
  async execute(input, ctx) {
978
1012
  const p = (input ?? {});
979
1013
  const from = p.from ?? p.fromAlias;
1014
+ const { text, html } = await resolveBody(p);
980
1015
  const email = {
981
1016
  to: normalizeAddressList(p.to) ?? "",
982
1017
  cc: normalizeAddressList(p.cc),
@@ -984,8 +1019,8 @@ export default function gmail(rl) {
984
1019
  replyTo: normalizeAddressList(p.replyTo),
985
1020
  from,
986
1021
  subject: p.subject ?? "",
987
- text: p.text,
988
- html: p.html,
1022
+ text,
1023
+ html,
989
1024
  attachments: p.attachments,
990
1025
  };
991
1026
  // When threading a draft, fetch the last Message-ID in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Code mode for agents — turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -63,11 +63,31 @@
63
63
  "typescript": "^5.8.0"
64
64
  },
65
65
  "peerDependencies": {
66
- "playwright": "^1.57.0"
66
+ "@react-email/components": "^1.0.12",
67
+ "@react-email/render": "^2.1.0",
68
+ "playwright": "^1.57.0",
69
+ "react": "^19.2.7",
70
+ "react-dom": "^19.2.7",
71
+ "sucrase": "^3.35.1"
67
72
  },
68
73
  "peerDependenciesMeta": {
74
+ "@react-email/components": {
75
+ "optional": true
76
+ },
77
+ "@react-email/render": {
78
+ "optional": true
79
+ },
69
80
  "playwright": {
70
81
  "optional": true
82
+ },
83
+ "react": {
84
+ "optional": true
85
+ },
86
+ "react-dom": {
87
+ "optional": true
88
+ },
89
+ "sucrase": {
90
+ "optional": true
71
91
  }
72
92
  },
73
93
  "dependencies": {