@engenty/wizards-runtime 0.7.10 → 0.8.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 (121) hide show
  1. package/LICENSE-FIGURES +37 -0
  2. package/README.md +8 -2
  3. package/TRADEMARKS.md +47 -0
  4. package/apps/runtime/dist/authoring/guide.js +14 -2
  5. package/apps/runtime/dist/cli/exec.js +4 -1
  6. package/apps/runtime/dist/connectors/builtin.js +4 -2
  7. package/apps/runtime/dist/connectors/index.js +35 -4
  8. package/apps/runtime/dist/connectors/smtp.js +221 -0
  9. package/apps/runtime/dist/db/migrations/tenant/0018_share_job.sql +18 -0
  10. package/apps/runtime/dist/db/migrations/tenant/meta/0018_snapshot.json +3460 -0
  11. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  12. package/apps/runtime/dist/db/schema.js +19 -0
  13. package/apps/runtime/dist/docs/dev/index.md +25 -13
  14. package/apps/runtime/dist/docs/dev/meta.json +8 -2
  15. package/apps/runtime/dist/docs/dev/wizard-format/agent-steps.md +112 -0
  16. package/apps/runtime/dist/docs/dev/wizard-format/branches.md +63 -0
  17. package/apps/runtime/dist/docs/dev/wizard-format/film-steps.md +47 -0
  18. package/apps/runtime/dist/docs/dev/wizard-format/generate-steps.md +57 -0
  19. package/apps/runtime/dist/docs/dev/wizard-format/index.md +188 -0
  20. package/apps/runtime/dist/docs/dev/wizard-format/lists-and-connections.md +65 -0
  21. package/apps/runtime/dist/docs/dev/wizard-format/meta.json +19 -0
  22. package/apps/runtime/dist/docs/dev/wizard-format/pages-and-fields.md +102 -0
  23. package/apps/runtime/dist/docs/dev/wizard-format/review-and-result.md +64 -0
  24. package/apps/runtime/dist/docs/dev/wizard-format/templates.md +57 -0
  25. package/apps/runtime/dist/docs/dev/wizard-format/validate.md +63 -0
  26. package/apps/runtime/dist/docs/dev/wizard-format/views-and-widgets.md +103 -0
  27. package/apps/runtime/dist/docs/dev/wizard-format/wizard-file.md +147 -0
  28. package/apps/runtime/dist/docs/user/run.md +21 -4
  29. package/apps/runtime/dist/docs/user/steps.md +26 -1
  30. package/apps/runtime/dist/docs/user/test-and-share.md +3 -2
  31. package/apps/runtime/dist/engine/requirements.js +22 -7
  32. package/apps/runtime/dist/engine/runner.js +17 -8
  33. package/apps/runtime/dist/env.js +5 -0
  34. package/apps/runtime/dist/harness/antigravity.js +2 -10
  35. package/apps/runtime/dist/harness/claude.js +2 -10
  36. package/apps/runtime/dist/harness/codex.js +5 -11
  37. package/apps/runtime/dist/harness/cursor.js +2 -10
  38. package/apps/runtime/dist/harness/env.js +23 -0
  39. package/apps/runtime/dist/harness/index.js +6 -3
  40. package/apps/runtime/dist/routes/runs.js +106 -6
  41. package/apps/runtime/dist/routes/studio.js +12 -0
  42. package/apps/runtime/dist/services/engenty-shapes.js +3 -2
  43. package/apps/runtime/dist/sharing/agent.js +65 -0
  44. package/apps/runtime/dist/sharing/files.js +121 -0
  45. package/apps/runtime/dist/sharing/index.js +298 -0
  46. package/apps/runtime/dist/sharing/jobs.js +186 -0
  47. package/apps/runtime/dist/sharing/state.js +116 -0
  48. package/apps/runtime/dist/sharing/targets/browser.js +131 -0
  49. package/apps/runtime/dist/sharing/targets/connector.js +63 -0
  50. package/apps/runtime/dist/sharing/targets/email.js +98 -0
  51. package/apps/runtime/dist/sharing/targets/slack.js +54 -0
  52. package/apps/runtime/dist/sharing/targets/storage.js +164 -0
  53. package/apps/runtime/dist/sharing/types.js +13 -0
  54. package/apps/runtime/dist/texts.js +108 -0
  55. package/apps/runtime/dist/tools/browser.js +48 -0
  56. package/apps/runtime/dist/tools/connector.js +3 -2
  57. package/apps/runtime/dist/tools/store.js +18 -1
  58. package/apps/web/dist/assets/AboutDialog-CiiSwL4t.js +1 -0
  59. package/apps/web/dist/assets/{ChangelogDialog-B0lS0BdL.js → ChangelogDialog-BEyz7LNE.js} +1 -1
  60. package/apps/web/dist/assets/ChatView-9RkWts5F.js +10 -0
  61. package/apps/web/dist/assets/EditorPage-CCsKKcYo.js +4 -0
  62. package/apps/web/dist/assets/{FlowPreview-oX_Emm7u.js → FlowPreview-CLf8WHiG.js} +1 -1
  63. package/apps/web/dist/assets/{Harness-Dv9ln5xB.js → Harness-jc3jpWvI.js} +2 -2
  64. package/apps/web/dist/assets/NewWizardChannels-BPGZ4pfP.js +1 -0
  65. package/apps/web/dist/assets/NewWizardPage-tidycL7Y.js +7 -0
  66. package/apps/web/dist/assets/OnboardingPage-DtVuaIuv.js +1 -0
  67. package/apps/web/dist/assets/ProjectPage-BsWgCP5z.js +1 -0
  68. package/apps/web/dist/assets/PublicRunner-CkA_etKX.js +1 -0
  69. package/apps/web/dist/assets/{ReadOnly-S9vGVQHr.js → ReadOnly-BVwbqvpi.js} +1 -1
  70. package/apps/web/dist/assets/RunDrawer-CkFpnWCC.js +4 -0
  71. package/apps/web/dist/assets/{RunnerView-BW1h4x63.js → RunnerView-CxTFM-lx.js} +1 -1
  72. package/apps/web/dist/assets/SettingsPage-DM1wNAE7.js +1 -0
  73. package/apps/web/dist/assets/SetupPage-BKnQqiab.js +1 -0
  74. package/apps/web/dist/assets/{SharePage-ktqm-jWI.js → SharePage-CTTSudtp.js} +1 -1
  75. package/apps/web/dist/assets/changelog-DBEwyB1f.js +1 -0
  76. package/apps/web/dist/assets/engenty-Cko4qGFV.js +1 -0
  77. package/apps/web/dist/assets/{esm-C-ZWeuhU.js → esm-DtNAg8uF.js} +1 -1
  78. package/apps/web/dist/assets/fluffy-engenty-Db7agOjV.js +520 -0
  79. package/apps/web/dist/assets/index-V1M59Yez.css +1 -0
  80. package/apps/web/dist/assets/index-xIz8znhq.js +14 -0
  81. package/apps/web/dist/assets/{oss-credits-Bc05Qv5H.js → oss-credits-BWE03Xax.js} +1 -1
  82. package/apps/web/dist/assets/{phone-BjmhIKuT.js → phone-5SYD9OrJ.js} +1 -1
  83. package/apps/web/dist/assets/{settings-menu-Dpkp3f56.js → settings-menu-Bn5-7Lqk.js} +1 -1
  84. package/apps/web/dist/assets/ui-s39eGOtW.js +137 -0
  85. package/apps/web/dist/assets/{upload-JX9BcMTq.js → upload-Bevcvx5V.js} +1 -1
  86. package/apps/web/dist/assets/{wrench-BSxnPs2_.js → wrench-ZD5SCsz9.js} +1 -1
  87. package/apps/web/dist/favicon.svg +2 -1
  88. package/apps/web/dist/icons/apple-touch-icon.png +0 -0
  89. package/apps/web/dist/icons/icon-192.png +0 -0
  90. package/apps/web/dist/icons/icon-512.png +0 -0
  91. package/apps/web/dist/icons/icon-maskable-512.png +0 -0
  92. package/apps/web/dist/index.html +4 -4
  93. package/apps/web/dist/mcp/flow.html +65 -120
  94. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +22 -0
  95. package/node_modules/@engenty-wizards/shared/dist/definition.js +26 -0
  96. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +6 -0
  97. package/node_modules/@engenty-wizards/shared/dist/run.js +2 -0
  98. package/node_modules/@engenty-wizards/shared/dist/sharing.d.ts +135 -0
  99. package/node_modules/@engenty-wizards/shared/dist/sharing.js +11 -0
  100. package/node_modules/@engenty-wizards/shared/dist/store.d.ts +8 -0
  101. package/package.json +6 -3
  102. package/plugin/.claude-plugin/plugin.json +1 -1
  103. package/plugin/hooks/engenty.ts +3 -2
  104. package/apps/runtime/dist/docs/dev/wizard-format.md +0 -125
  105. package/apps/web/dist/assets/AboutDialog-B2LuZ_mC.js +0 -1
  106. package/apps/web/dist/assets/ChatView-BcPDcU2d.js +0 -10
  107. package/apps/web/dist/assets/EditorPage-erduYKNM.js +0 -4
  108. package/apps/web/dist/assets/NewWizardChannels-JveYyh-U.js +0 -1
  109. package/apps/web/dist/assets/NewWizardPage-Dfk-WOhX.js +0 -7
  110. package/apps/web/dist/assets/OnboardingPage-CT9dL4ke.js +0 -1
  111. package/apps/web/dist/assets/ProjectPage-5_Wsk9Hv.js +0 -1
  112. package/apps/web/dist/assets/PublicRunner-Cw1WzTze.js +0 -1
  113. package/apps/web/dist/assets/RunDrawer-O4rEFCL8.js +0 -4
  114. package/apps/web/dist/assets/SettingsPage-DmhN7OTy.js +0 -1
  115. package/apps/web/dist/assets/SetupPage-BlWd7Uln.js +0 -1
  116. package/apps/web/dist/assets/changelog-FJJS0Tpt.js +0 -1
  117. package/apps/web/dist/assets/engenty-WpMi3xwq.js +0 -1
  118. package/apps/web/dist/assets/fluffy-engenty-DPNHytrz.js +0 -575
  119. package/apps/web/dist/assets/index-C4aHMcg9.css +0 -1
  120. package/apps/web/dist/assets/index-xH9uN8Nh.js +0 -14
  121. package/apps/web/dist/assets/ui-DMmT-cSO.js +0 -137
@@ -0,0 +1,37 @@
1
+ # engenty figures
2
+
3
+ SPDX-License-Identifier: CC-BY-4.0
4
+
5
+ Copyright 2026 - Thru The Heart Creative GmbH - Matthias Platzer
6
+
7
+ The engenty figures — the one-eyed characters of engenty, in every kind and rendering, with
8
+ their shapes, colours, eyes and accessories — are licensed under the Creative Commons
9
+ Attribution 4.0 International licence (CC BY 4.0):
10
+ https://creativecommons.org/licenses/by/4.0/legalcode
11
+
12
+ You may use, copy, change and share them, commercially too, free of charge and without
13
+ royalties, as long as you give credit, for example:
14
+
15
+ engenty figures © Thru The Heart Creative GmbH - Matthias Platzer, CC BY 4.0, engenty.ai
16
+
17
+ These files describe what the figures look like and are under CC BY 4.0:
18
+
19
+ - apps/web/src/engenty/engenty.tsx, colors.ts, forms.ts, helmet.tsx, logo.tsx,
20
+ goose-helmet-fit.ts, assets/goose-helmet.bin, assets/goose-helmet-livery.webp
21
+ - apps/runtime/src/services/engenty-shapes.ts, plugin/hooks/engenty.ts
22
+ - assets/engenty/: every flat figure as a 512 px PNG, plain and, for round, drop, dome, flame,
23
+ oval, sprout and tower, with the helmet
24
+ - apps/web/public/favicon.svg, apps/web/public/icons/, apps/docs/public/favicon.svg
25
+ - docs/assets/engenty.svg
26
+
27
+ The figures are under CC BY 4.0 wherever else they appear in this repository too: in the
28
+ screenshots docs/assets/setup.png, studio.png and wizard.png, and as the pixel engenty that
29
+ apps/web/public/install.sh draws. The rest of those files is under the Functional Source License.
30
+
31
+ The code that renders, animates and deforms the figures (the fur and jelly renderers and
32
+ their shaders, the helmet's geometry and shaders, gaze and motion) stays under the Functional
33
+ Source License (LICENSE).
34
+
35
+ CC BY 4.0 licenses copyright and similar rights, not trademark or patent rights (section
36
+ 2(b)(2)). Using the figures or the name engenty as your own brand, logo or mascot, or in a way
37
+ that suggests engenty endorses you, is governed by TRADEMARKS.md.
package/README.md CHANGED
@@ -21,6 +21,9 @@
21
21
  - **Share it with a link.** Anyone can run a wizard in the browser, on a phone too: camera,
22
22
  code scan, voice notes, location and a drawn signature included. Results download as PDF,
23
23
  Word, HTML, Markdown, PNG, MP4, MP3, CSV, Excel or JSON, or go out as a link.
24
+ - **Send the result on.** From the result, by email, into Google Drive, OneDrive or S3, to a
25
+ Slack channel, through any connected service, or onto a website the wizard opens in its
26
+ browser – you sign in yourself and confirm before anything goes public.
24
27
  - **Runs on your machine, on your subscription.** It uses Codex, Claude Code, Antigravity
25
28
  or Cursor Agent when one is installed and signed in, so no API key is needed. Or bring a key
26
29
  (Vercel AI Gateway, OpenAI, Anthropic), or a local model through Ollama. Your wizards and
@@ -130,7 +133,8 @@ Change it later under Settings → Models.
130
133
  - **Make a wizard:** describe it in the studio’s chat, or start from one of the templates.
131
134
  You see the steps as a diagram and can change any of them.
132
135
  - **Run and share:** try it yourself, then share the link. Shared results are kept for 7 days
133
- for people without an account.
136
+ for people without an account. “Teilen” on a result also sends it by email, saves it to a
137
+ cloud folder or posts it: [Sharing a result](docs/development.md#sharing-a-result).
134
138
  - **Connect services:** Gmail, Google Drive, Calendar, Contacts, Outlook, OneDrive, Slack,
135
139
  GitHub, HubSpot and S3 are built in; any other service can be added from its API description
136
140
  or MCP server through the [integrations.sh](https://integrations.sh) registry. Services that
@@ -212,5 +216,7 @@ and is a site of its own: `pnpm dev:docs`.
212
216
 
213
217
  [FSL-1.1-MIT](LICENSE): use it for yourself and your company, run it, change it – just don’t
214
218
  offer it to others as a competing product or service. Each release becomes MIT two years
215
- after it is published. [COPYRIGHTS.md](COPYRIGHTS.md) names the owner, the marks, what is not ours and the
219
+ after it is published. The engenty figures are free artwork under CC BY 4.0
220
+ ([LICENSE-FIGURES](LICENSE-FIGURES)); the name and the figures as a brand are trademarks
221
+ ([TRADEMARKS.md](TRADEMARKS.md)). [COPYRIGHTS.md](COPYRIGHTS.md) names the owner, the marks, what is not ours and the
216
222
  third-party software with its licences.
package/TRADEMARKS.md ADDED
@@ -0,0 +1,47 @@
1
+ # Trademarks
2
+
3
+ "engenty", "engenty.wizards" (also written "engenty wizards"), the engenty wordmark and the
4
+ engenty figures are trademarks of Thru The Heart Creative GmbH. The word "wizards" on its own is
5
+ not one of them: it names the package, the repository and the command.
6
+
7
+ engenty comes under several licences. In this repository: most of the code under the Functional
8
+ Source License ([LICENSE](LICENSE)); the files of `packages/shared` that define the wizard
9
+ format also under MIT, at your choice (`FSL-1.1-MIT OR MIT`,
10
+ [packages/shared/LICENSE-MIT](packages/shared/LICENSE-MIT)); the runner package under MIT
11
+ ([packages/runner/LICENSE](packages/runner/LICENSE)); the figures as artwork under CC BY 4.0
12
+ ([LICENSE-FIGURES](LICENSE-FIGURES)). Outside it: the WordPress plugins under GPL-2.0-or-later,
13
+ and the closed parts (engenty.ai, the mobile and desktop apps, the Pro plugins, also when a
14
+ local install receives them) under no public licence. No licence of any part grants trademark
15
+ rights. These rules say how the name and the figures may be used as signs, whichever licence the
16
+ part you use is under.
17
+
18
+ ## Fine without asking
19
+
20
+ - Talking about engenty, truthfully: posts, reports, reviews, tutorials, courses, videos,
21
+ streams, podcasts, articles and talks, and saying that your wizard, plugin, connector or app
22
+ works with, runs on or is about engenty.
23
+ - Showing the figures in that content, thumbnails and preview images included, and in
24
+ documentation, screenshots and the wizards and apps built with engenty, with the credit
25
+ CC BY 4.0 asks for (for a video or a post, its description is enough).
26
+ - Changing the figures, drawing new poses, making fan art.
27
+ - Running and sharing engenty.wizards and your own changed copy of it, as the licences allow,
28
+ with its engenties inside.
29
+
30
+ ## Needs our written permission
31
+
32
+ - Using an engenty, a figure made from one or one that looks like one, as your own logo, mascot,
33
+ app icon, avatar or profile picture, or as the recurring character of your channel or brand.
34
+ - Using "engenty" or a figure in the name of your product, service, company, domain or social
35
+ media account.
36
+ - Advertising your own product or service (other than content about engenty) with an engenty as
37
+ its face, or anything else that suggests engenty made, endorses, sponsors or is part of what
38
+ you offer.
39
+ - Merchandise: selling or giving away goods (stickers, shirts, toys, prints) with the figures.
40
+ - Registering "engenty", the figures or anything like them as a trademark, design or domain.
41
+
42
+ A changed copy of engenty.wizards that you offer to others must carry its own name and logo; it
43
+ may keep the engenties inside the software, but not on its name, logo or advertising.
44
+
45
+ ## Ask
46
+
47
+ contact@engenty.ai — say what you plan, we usually answer quickly and often say yes.
@@ -282,7 +282,8 @@ type ReviewStep = { type: "review", show: (stepId | "lists.<listId>")[], edit?:
282
282
  // shows earlier outputs; the person accepts, edits text outputs (edit), or asks for a new version with a note (regenerate)
283
283
  // a shown list is edited in place by the person
284
284
 
285
- type ResultStep = { type: "result", message?: string, deliverables: { from: stepId | "lists.<listId>", label?, formats: Format[] }[] }
285
+ type ResultStep = { type: "result", message?: string, deliverables: { from: stepId | "lists.<listId>", label?, formats: Format[] }[],
286
+ share?: { target: string, label?: string, values?: { [fieldId]: string } }[] }
286
287
  // formats per source: image→png · video→mp4 (several results: zip) · voice→mp3 · document→pdf docx html md png · dashboard→html pdf png
287
288
  // film widget (video: true)→mp4 png · film→mp4
288
289
  // widget→html png pdf mp4 json (mp4 only when the widget registers a timeline)
@@ -290,6 +291,16 @@ type ResultStep = { type: "result", message?: string, deliverables: { from: step
290
291
  // agent json with schema→json csv xlsx md pdf
291
292
  // agent, any format→zip: the FILES the step kept with mail_save / browser_download
292
293
  // lists.<id>→xlsx csv json md · zip (a list with check.file: the kept files its rows name, plus the list)
294
+ // share: where the person can send or publish the result with one button, besides the link and the downloads. They pick the
295
+ // target, connect their account once, check the prefilled form and send. Targets and the fields "values" can prefill:
296
+ // "email" (to, subject, message — the picked files go as attachments) · "slack" (channel, message)
297
+ // "storage:google-drive" | "storage:microsoft-onedrive" | "storage:s3" (folder)
298
+ // "connector:<connectorId>" (instructions — an agent does it with the connector's actions and asks the person before every change)
299
+ // "browser" (url, instructions — an agent posts it on any website in the wizard's browser; the person signs in themselves and confirms before it goes public)
300
+ // Left out: every target that needs no model. []: none. A list: exactly these, in this order — "browser" and "connector:…" then run
301
+ // for END USERS too, on the admin's credits. "label" names the button ("Auf LinkedIn posten"); values take {{templates}}:
302
+ // { target: "email", label: "An mich senden", values: { to: "{{email}}", subject: "Dein Angebot von {{brand.name}}" } }
303
+ // { target: "browser", label: "Auf LinkedIn posten", values: { url: "https://www.linkedin.com/feed/", instructions: "Erstelle einen Beitrag mit dem Text und dem Bild." } }
293
304
 
294
305
  Templates (in instructions/prompt): {{fieldId}} · {{steps.stepId}} (whole output) · {{steps.stepId.key}} (json key)
295
306
  · {{itemsField}} (line items as a table WITH computed net/VAT/total) · {{itemsField.net|vat|gross}} · {{today}}
@@ -317,7 +328,8 @@ How good wizards look:
317
328
  - Money/totals: use an "items" field with vat — the runner computes totals; documents must use {{items}} verbatim. Never let a model compute totals.
318
329
  - Facts from the web need an agent step with web_search + web_fetch BEFORE writing; documents then cite those notes.
319
330
  - Live data with a free public JSON API (weather and wind: api.open-meteo.com; sunrise/sunset is in it too) → an agent step with the http tool and model "standard" that calls the API and returns json. Far cheaper and more exact than web research; use web research only when no API exists.
320
- - Writing into other systems (CRM, database, spreadsheet, website): an agent step with the matching mcp server, http, or browser tool, preceded by a review step.
331
+ - Writing into other systems (CRM, database, spreadsheet, website): an agent step with the matching mcp server, http, or browser tool, preceded by a review step. Such a step hands over an earlier step's output as a file by naming it "result:<stepId>.<format>" — to browser_upload, or as "file:result:<stepId>.<format>" where a connector action takes "*_base64".
332
+ - Sending the finished result on (mail it, file it in a cloud folder, post it on social media or a website, put it into an ads account): do not build a step for it. Put the targets into the result step's "share": the person sees the result first and sends it with one button. Build an agent step only when the wizard must write somewhere without being asked.
321
333
  ${sandboxGuideLine()}
322
334
  - Remembering between runs ("merke dir", "führe eine Liste"): declare a list and let an agent step save into it with list_write; a later run reads it as {{lists.id}} or list_read. Give it a key column so known rows are updated, not duplicated. Never make the person re-upload last time's result.
323
335
  - Collecting from everyone who runs the wizard (requests, leads, sign-ups, feedback): a list with "shared": true, saved with list_write. It is the admin's, under Space → Data; never show one person's rows to another. Longer text for every run (a running summary, a log): the "pages" tool.
@@ -47,7 +47,8 @@ export function runLogged(command, args, options = {}) {
47
47
  }
48
48
  /**
49
49
  * Runs a file and gives what it printed, like execFile: it rejects when the file is not there,
50
- * ends with another code than 0, or takes longer than `timeout`.
50
+ * ends with another code than 0, or takes longer than `timeout` (then with `killed` and `signal`
51
+ * set, as execFile does).
51
52
  */
52
53
  export function runFile(file, args, options = {}) {
53
54
  return new Promise((resolve, reject) => {
@@ -73,6 +74,8 @@ export function runFile(file, args, options = {}) {
73
74
  else {
74
75
  reject(Object.assign(new Error(`${file} ended with ${signal ?? code}`), {
75
76
  code,
77
+ signal,
78
+ killed: child.killed,
76
79
  stdout,
77
80
  stderr,
78
81
  }));
@@ -13,11 +13,12 @@ import { managed } from "../manage.js";
13
13
  import { gmailConnector as mailGmail } from "./gmail.js";
14
14
  import { imapConnector } from "./imap.js";
15
15
  import { outlookConnector as mailOutlook } from "./outlook.js";
16
+ import { smtpConnector } from "./smtp.js";
16
17
  import { googleThroughAccount } from "./via-account.js";
17
18
  /**
18
19
  * engenty's own connectors, as engenty ships them: Gmail, Drive, Calendar, Contacts, Outlook,
19
- * OneDrive, Slack, GitHub, HubSpot, S3. Every project has them; an OAuth one can be connected
20
- * once its client is configured.
20
+ * OneDrive, Slack, GitHub, HubSpot, S3 — and SMTP, for sending from any mailbox. Every project
21
+ * has them; an OAuth one can be connected once its client is configured.
21
22
  */
22
23
  export const BUILTIN_CONNECTORS = [
23
24
  ...googleConnectors,
@@ -27,6 +28,7 @@ export const BUILTIN_CONNECTORS = [
27
28
  githubConnector,
28
29
  hubspotConnector,
29
30
  s3Connector,
31
+ smtpConnector,
30
32
  ];
31
33
  /** The connectors behind a connection of kind "mail": one read-only shape for any mailbox. */
32
34
  export const MAIL_CONNECTORS = [mailGmail, mailOutlook, imapConnector];
@@ -14,7 +14,7 @@ import { resolveConnector } from "./external.js";
14
14
  import { AccountConnectError, redeemTicket, refreshThroughAccount, startThroughAccount, } from "./via-account.js";
15
15
  /** The connectors a person can pick for each kind of connection. */
16
16
  const CONNECTORS = { mail: MAIL_CONNECTORS };
17
- const HINTS = { imap: "imapHint" };
17
+ const HINTS = { imap: "imapHint", smtp: "imapHint" };
18
18
  /** The built-in connectors' fields in each language; their definitions say them in English. */
19
19
  const FIELD_WORDS = {
20
20
  de: {
@@ -23,6 +23,11 @@ const FIELD_WORDS = {
23
23
  password: { label: "Passwort oder App-Passwort" },
24
24
  host: { label: "IMAP-Server", placeholder: "leer lassen bei Gmail, GMX, iCloud …" },
25
25
  },
26
+ smtp: {
27
+ email: { label: "E-Mail-Adresse", placeholder: "name@firma.at" },
28
+ password: { label: "Passwort oder App-Passwort" },
29
+ host: { label: "SMTP-Server", placeholder: "leer lassen bei Gmail, GMX, iCloud …" },
30
+ },
26
31
  },
27
32
  };
28
33
  /**
@@ -30,7 +35,8 @@ const FIELD_WORDS = {
30
35
  * may use needs. A wizard that only reads never asks for the right to send or delete.
31
36
  */
32
37
  function scopesFor(connection, connector) {
33
- const scopes = new Set(scopesForGroups(connector, new Set(["read"])));
38
+ // A share target's connection asks for what its actions need and nothing to read with.
39
+ const scopes = new Set(scopesForGroups(connector, new Set(connection.target ? [] : ["read"])));
34
40
  for (const action of connector.actions) {
35
41
  if (connection.actions?.includes(action.id)) {
36
42
  for (const scope of action.providerScopes ?? []) {
@@ -40,6 +46,24 @@ function scopesFor(connection, connector) {
40
46
  }
41
47
  return [...scopes];
42
48
  }
49
+ const sources = [];
50
+ export function registerConnectionSource(source) {
51
+ sources.push(source);
52
+ }
53
+ /** A connection of a run by id: one the wizard declares, or one of a share target. */
54
+ export async function findConnection(run, connectionId, projectId) {
55
+ const declared = run.definition.connections?.find((c) => c.id === connectionId);
56
+ if (declared) {
57
+ return declared;
58
+ }
59
+ for (const source of sources) {
60
+ const found = await source(run, connectionId, projectId);
61
+ if (found) {
62
+ return found;
63
+ }
64
+ }
65
+ return null;
66
+ }
43
67
  /** Where the provider sends the person back; must be registered with the OAuth client. */
44
68
  export function connectRedirectUri() {
45
69
  return env.connectRedirectUrl || `${env.appUrl}/api/connect/callback`;
@@ -47,8 +71,15 @@ export function connectRedirectUri() {
47
71
  const slot = (connectionId) => `connection:${connectionId}`;
48
72
  /** A connector that needs no credential: there is nothing for the person to connect. */
49
73
  const open = (connector) => connector.auth.kind === "api_key" && connector.auth.apiKey.fields.length === 0;
50
- /** The connectors behind a connection: the built-in ones of its kind, or the one it names. */
74
+ /**
75
+ * The connectors behind a connection: the built-in ones of its kind, the one it names, or the
76
+ * ones a share target lets the person pick from.
77
+ */
51
78
  async function candidates(connection, projectId) {
79
+ if (connection.target) {
80
+ const found = await Promise.all(connection.target.connectors.map((id) => resolveConnector(projectId, id)));
81
+ return found.flatMap((f) => (f ? [f.connector] : []));
82
+ }
52
83
  if (connection.connector) {
53
84
  const found = await resolveConnector(projectId, connection.connector);
54
85
  return found ? [found.connector] : [];
@@ -173,7 +204,7 @@ export async function finishOAuth(answer, rawState) {
173
204
  throw new Error("The sign-in has expired. Connect again.");
174
205
  }
175
206
  const run = await db.query.run.findFirst({ where: eq(schema.run.id, state.runId) });
176
- const connection = run?.definition.connections?.find((c) => c.id === state.connectionId);
207
+ const connection = run ? await findConnection(run, state.connectionId, state.projectId) : null;
177
208
  if (!connection) {
178
209
  throw new Error("The run this sign-in belongs to is gone.");
179
210
  }
@@ -0,0 +1,221 @@
1
+ import nodemailer from "nodemailer";
2
+ import MailComposer from "nodemailer/lib/mail-composer/index.js";
3
+ import { z } from "zod";
4
+ import { env } from "../env.js";
5
+ import { assertPublicUrl } from "../tools/net-guard.js";
6
+ const KNOWN_HOSTS = {
7
+ "gmail.com": "smtp.gmail.com",
8
+ "googlemail.com": "smtp.gmail.com",
9
+ "outlook.com": "smtp-mail.outlook.com",
10
+ "hotmail.com": "smtp-mail.outlook.com",
11
+ "live.com": "smtp-mail.outlook.com",
12
+ "icloud.com": "smtp.mail.me.com",
13
+ "me.com": "smtp.mail.me.com",
14
+ "yahoo.com": "smtp.mail.yahoo.com",
15
+ "gmx.at": "mail.gmx.net",
16
+ "gmx.de": "mail.gmx.net",
17
+ "gmx.net": "mail.gmx.net",
18
+ "web.de": "smtp.web.de",
19
+ "a1.net": "securemail.a1.net",
20
+ "fastmail.com": "smtp.fastmail.com",
21
+ };
22
+ const ALLOW_PRIVATE = process.env.ALLOW_PRIVATE_FETCH === "1";
23
+ async function transportOf(credentials) {
24
+ const email = credentials.email.trim();
25
+ const domain = email.split("@")[1]?.toLowerCase() ?? "";
26
+ const host = credentials.host?.trim() || KNOWN_HOSTS[domain] || `smtp.${domain}`;
27
+ const port = Number(credentials.port) || 587;
28
+ if (!ALLOW_PRIVATE && port !== 587 && port !== 465) {
29
+ throw new Error("SMTP runs on port 587 (or 465).");
30
+ }
31
+ // The host is typed by the person: it must be a public mail server, never this server's network.
32
+ if (!ALLOW_PRIVATE) {
33
+ await assertPublicUrl(`https://${host}`);
34
+ }
35
+ return nodemailer.createTransport({
36
+ host,
37
+ port,
38
+ secure: port === 465,
39
+ auth: { user: email, pass: credentials.password },
40
+ connectionTimeout: 20_000,
41
+ socketTimeout: 60_000,
42
+ ...(ALLOW_PRIVATE ? { tls: { rejectUnauthorized: false } } : {}),
43
+ });
44
+ }
45
+ /** A failed sign-in reads the same whichever mail library met it. */
46
+ function asConnectError(err) {
47
+ const e = err;
48
+ if (e?.code === "EAUTH" || e?.responseCode === 535) {
49
+ return Object.assign(new Error("authentication failed"), { authenticationFailed: true });
50
+ }
51
+ if (e?.code === "EDNS" || e?.code === "ESOCKET" || e?.code === "ECONNECTION") {
52
+ return Object.assign(new Error("server not reachable"), { code: "ENOTFOUND" });
53
+ }
54
+ return err;
55
+ }
56
+ const address = (list) => list.map((a) => a.trim()).filter(Boolean);
57
+ function mailOptions(message, from) {
58
+ return {
59
+ ...(from ? { from } : {}),
60
+ to: address(message.to),
61
+ cc: address(message.cc ?? []),
62
+ subject: message.subject,
63
+ text: message.text,
64
+ attachments: (message.attachments ?? []).map((a) => ({
65
+ filename: a.name,
66
+ contentType: a.mime,
67
+ content: Buffer.from(a.data),
68
+ })),
69
+ };
70
+ }
71
+ /** The message as RFC 822 bytes, for providers that take a finished mail (Gmail, Outlook). */
72
+ export function mimeOf(message) {
73
+ return new MailComposer(mailOptions(message)).compile().build();
74
+ }
75
+ async function sendSmtp(credentials, message) {
76
+ const transport = await transportOf(credentials);
77
+ try {
78
+ await transport.sendMail(mailOptions(message, credentials.email.trim()));
79
+ }
80
+ finally {
81
+ transport.close();
82
+ }
83
+ }
84
+ // --- the server's own address -------------------------------------------------
85
+ /** The address the server sends from when `SMTP_URL` is set, else null. */
86
+ export function serverSender() {
87
+ return env.mail.smtpUrl ? env.mail.from || null : null;
88
+ }
89
+ /** Sends from the server's own address: nobody has to connect a mailbox. */
90
+ export async function sendFromServer(message) {
91
+ const from = serverSender();
92
+ if (!from) {
93
+ throw new Error("This server has no address to send from.");
94
+ }
95
+ const transport = nodemailer.createTransport(env.mail.smtpUrl);
96
+ try {
97
+ await transport.sendMail(mailOptions(message, from));
98
+ }
99
+ finally {
100
+ transport.close();
101
+ }
102
+ }
103
+ // --- the person's own account --------------------------------------------------
104
+ /** Graph takes a finished mail only up to its request limit of 4 MB (base64 of the bytes). */
105
+ const OUTLOOK_MIME_LIMIT = 2_900_000;
106
+ /** The connectors a mail can be sent with, as the person's own account. */
107
+ export const MAIL_SENDERS = ["smtp", "google-gmail", "microsoft-outlook"];
108
+ /** Sends as the connected account, whichever connector it was connected with. */
109
+ export async function sendAsAccount(connector, ctx, message) {
110
+ if (connector.id === "smtp") {
111
+ await sendSmtp(JSON.parse(ctx.accessToken), message);
112
+ return;
113
+ }
114
+ const mime = await mimeOf(message);
115
+ if (connector.id === "google-gmail") {
116
+ const res = await ctx.fetchImpl("https://gmail.googleapis.com/upload/gmail/v1/users/me/messages/send?uploadType=media", {
117
+ method: "POST",
118
+ headers: { Authorization: `Bearer ${ctx.accessToken}`, "Content-Type": "message/rfc822" },
119
+ body: new Uint8Array(mime),
120
+ });
121
+ if (!res.ok) {
122
+ throw new Error(`Gmail (${res.status}): ${(await res.text()).slice(0, 200)}`);
123
+ }
124
+ return;
125
+ }
126
+ if (connector.id === "microsoft-outlook") {
127
+ if (mime.byteLength > OUTLOOK_MIME_LIMIT) {
128
+ throw new MailTooLarge();
129
+ }
130
+ const res = await ctx.fetchImpl("https://graph.microsoft.com/v1.0/me/sendMail", {
131
+ method: "POST",
132
+ headers: { Authorization: `Bearer ${ctx.accessToken}`, "Content-Type": "text/plain" },
133
+ body: mime.toString("base64"),
134
+ });
135
+ if (!res.ok) {
136
+ throw new Error(`Outlook (${res.status}): ${(await res.text()).slice(0, 200)}`);
137
+ }
138
+ return;
139
+ }
140
+ throw new Error(`${connector.name} cannot send mail.`);
141
+ }
142
+ /** The attachments are more than the account's provider takes in one mail. */
143
+ export class MailTooLarge extends Error {
144
+ }
145
+ // --- the connector ----------------------------------------------------------------
146
+ const sendInput = z.object({
147
+ to: z.array(z.string().min(3)).min(1).max(20).describe("Recipient addresses."),
148
+ cc: z.array(z.string().min(3)).max(20).optional(),
149
+ subject: z.string().describe("Subject line."),
150
+ body_text: z.string().describe("Plain-text body."),
151
+ attachment_base64: z
152
+ .string()
153
+ .optional()
154
+ .describe("One attachment: `file:<path>` of a kept file or `upload:<id>`."),
155
+ attachment_name: z.string().optional().describe("File name of the attachment."),
156
+ });
157
+ export const smtpConnector = {
158
+ id: "smtp",
159
+ moduleId: "wizards-mail",
160
+ name: "E-Mail (SMTP)",
161
+ description: "Send mail from any mailbox over SMTP (GMX, iCloud, your own domain …).",
162
+ icon: "mail",
163
+ toolPrefix: "smtp",
164
+ auth: {
165
+ kind: "api_key",
166
+ apiKey: {
167
+ fields: [
168
+ { key: "email", label: "Email address", placeholder: "name@company.com" },
169
+ { key: "password", label: "Password or app password", secret: true },
170
+ {
171
+ key: "host",
172
+ label: "SMTP server",
173
+ placeholder: "leave empty for Gmail, GMX, iCloud …",
174
+ required: false,
175
+ },
176
+ { key: "port", label: "Port", placeholder: "587", required: false },
177
+ ],
178
+ verify: async (credentials) => {
179
+ const transport = await transportOf(credentials);
180
+ try {
181
+ await transport.verify();
182
+ }
183
+ catch (err) {
184
+ throw asConnectError(err);
185
+ }
186
+ finally {
187
+ transport.close();
188
+ }
189
+ return { label: credentials.email.trim(), externalId: credentials.email.trim() };
190
+ },
191
+ },
192
+ },
193
+ actions: [
194
+ {
195
+ id: "send_message",
196
+ summary: "Send an e-mail",
197
+ description: "Send an e-mail from the connected mailbox right away (plain text, one optional attachment). Irreversible.",
198
+ group: "destructive",
199
+ inputSchema: sendInput,
200
+ handler: async (raw, ctx) => {
201
+ const input = sendInput.parse(raw);
202
+ await sendSmtp(JSON.parse(ctx.accessToken), {
203
+ to: input.to,
204
+ cc: input.cc,
205
+ subject: input.subject,
206
+ text: input.body_text,
207
+ attachments: input.attachment_base64
208
+ ? [
209
+ {
210
+ name: input.attachment_name || "attachment",
211
+ mime: "application/octet-stream",
212
+ data: Buffer.from(input.attachment_base64, "base64"),
213
+ },
214
+ ]
215
+ : [],
216
+ });
217
+ return { sent: true, to: input.to, subject: input.subject };
218
+ },
219
+ },
220
+ ],
221
+ };
@@ -0,0 +1,18 @@
1
+ CREATE TABLE `share_job` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `run_id` text NOT NULL,
4
+ `tenant_id` text NOT NULL,
5
+ `target` text NOT NULL,
6
+ `name` text NOT NULL,
7
+ `icon` text NOT NULL,
8
+ `status` text NOT NULL,
9
+ `request` text NOT NULL,
10
+ `events` text DEFAULT '[]' NOT NULL,
11
+ `result` text,
12
+ `error` text,
13
+ `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
14
+ `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
15
+ FOREIGN KEY (`run_id`) REFERENCES `run`(`id`) ON UPDATE no action ON DELETE cascade
16
+ );
17
+ --> statement-breakpoint
18
+ CREATE INDEX `share_job_run` ON `share_job` (`run_id`,`created_at`);