@engenty/wizards-runtime 0.7.11 → 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.
- package/README.md +5 -1
- package/apps/runtime/dist/authoring/guide.js +14 -2
- package/apps/runtime/dist/cli/exec.js +4 -1
- package/apps/runtime/dist/connectors/builtin.js +4 -2
- package/apps/runtime/dist/connectors/index.js +35 -4
- package/apps/runtime/dist/connectors/smtp.js +221 -0
- package/apps/runtime/dist/db/migrations/tenant/0018_share_job.sql +18 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/0018_snapshot.json +3460 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
- package/apps/runtime/dist/db/schema.js +19 -0
- package/apps/runtime/dist/docs/dev/index.md +25 -13
- package/apps/runtime/dist/docs/dev/meta.json +8 -2
- package/apps/runtime/dist/docs/dev/wizard-format/agent-steps.md +112 -0
- package/apps/runtime/dist/docs/dev/wizard-format/branches.md +63 -0
- package/apps/runtime/dist/docs/dev/wizard-format/film-steps.md +47 -0
- package/apps/runtime/dist/docs/dev/wizard-format/generate-steps.md +57 -0
- package/apps/runtime/dist/docs/dev/wizard-format/index.md +188 -0
- package/apps/runtime/dist/docs/dev/wizard-format/lists-and-connections.md +65 -0
- package/apps/runtime/dist/docs/dev/wizard-format/meta.json +19 -0
- package/apps/runtime/dist/docs/dev/wizard-format/pages-and-fields.md +102 -0
- package/apps/runtime/dist/docs/dev/wizard-format/review-and-result.md +64 -0
- package/apps/runtime/dist/docs/dev/wizard-format/templates.md +57 -0
- package/apps/runtime/dist/docs/dev/wizard-format/validate.md +63 -0
- package/apps/runtime/dist/docs/dev/wizard-format/views-and-widgets.md +103 -0
- package/apps/runtime/dist/docs/dev/wizard-format/wizard-file.md +147 -0
- package/apps/runtime/dist/docs/user/run.md +21 -4
- package/apps/runtime/dist/docs/user/steps.md +26 -1
- package/apps/runtime/dist/docs/user/test-and-share.md +3 -2
- package/apps/runtime/dist/engine/requirements.js +22 -7
- package/apps/runtime/dist/engine/runner.js +17 -8
- package/apps/runtime/dist/env.js +5 -0
- package/apps/runtime/dist/harness/antigravity.js +2 -10
- package/apps/runtime/dist/harness/claude.js +2 -10
- package/apps/runtime/dist/harness/codex.js +5 -11
- package/apps/runtime/dist/harness/cursor.js +2 -10
- package/apps/runtime/dist/harness/env.js +23 -0
- package/apps/runtime/dist/harness/index.js +6 -3
- package/apps/runtime/dist/routes/runs.js +106 -6
- package/apps/runtime/dist/routes/studio.js +12 -0
- package/apps/runtime/dist/sharing/agent.js +65 -0
- package/apps/runtime/dist/sharing/files.js +121 -0
- package/apps/runtime/dist/sharing/index.js +298 -0
- package/apps/runtime/dist/sharing/jobs.js +186 -0
- package/apps/runtime/dist/sharing/state.js +116 -0
- package/apps/runtime/dist/sharing/targets/browser.js +131 -0
- package/apps/runtime/dist/sharing/targets/connector.js +63 -0
- package/apps/runtime/dist/sharing/targets/email.js +98 -0
- package/apps/runtime/dist/sharing/targets/slack.js +54 -0
- package/apps/runtime/dist/sharing/targets/storage.js +164 -0
- package/apps/runtime/dist/sharing/types.js +13 -0
- package/apps/runtime/dist/texts.js +108 -0
- package/apps/runtime/dist/tools/browser.js +48 -0
- package/apps/runtime/dist/tools/connector.js +3 -2
- package/apps/runtime/dist/tools/store.js +18 -1
- package/apps/web/dist/assets/AboutDialog-CiiSwL4t.js +1 -0
- package/apps/web/dist/assets/{ChangelogDialog-CZrd-MgC.js → ChangelogDialog-BEyz7LNE.js} +1 -1
- package/apps/web/dist/assets/ChatView-9RkWts5F.js +10 -0
- package/apps/web/dist/assets/EditorPage-CCsKKcYo.js +4 -0
- package/apps/web/dist/assets/{FlowPreview-oX_Emm7u.js → FlowPreview-CLf8WHiG.js} +1 -1
- package/apps/web/dist/assets/{Harness-DiB8CBdY.js → Harness-jc3jpWvI.js} +2 -2
- package/apps/web/dist/assets/NewWizardChannels-BPGZ4pfP.js +1 -0
- package/apps/web/dist/assets/NewWizardPage-tidycL7Y.js +7 -0
- package/apps/web/dist/assets/OnboardingPage-DtVuaIuv.js +1 -0
- package/apps/web/dist/assets/ProjectPage-BsWgCP5z.js +1 -0
- package/apps/web/dist/assets/PublicRunner-CkA_etKX.js +1 -0
- package/apps/web/dist/assets/{ReadOnly-CD5YWvrW.js → ReadOnly-BVwbqvpi.js} +1 -1
- package/apps/web/dist/assets/RunDrawer-CkFpnWCC.js +4 -0
- package/apps/web/dist/assets/{RunnerView-BW1h4x63.js → RunnerView-CxTFM-lx.js} +1 -1
- package/apps/web/dist/assets/SettingsPage-DM1wNAE7.js +1 -0
- package/apps/web/dist/assets/SetupPage-BKnQqiab.js +1 -0
- package/apps/web/dist/assets/{SharePage-B6u9kS12.js → SharePage-CTTSudtp.js} +1 -1
- package/apps/web/dist/assets/changelog-DBEwyB1f.js +1 -0
- package/apps/web/dist/assets/{esm-C-ZWeuhU.js → esm-DtNAg8uF.js} +1 -1
- package/apps/web/dist/assets/index-V1M59Yez.css +1 -0
- package/apps/web/dist/assets/index-xIz8znhq.js +14 -0
- package/apps/web/dist/assets/{oss-credits-Bc05Qv5H.js → oss-credits-BWE03Xax.js} +1 -1
- package/apps/web/dist/assets/{phone-BjmhIKuT.js → phone-5SYD9OrJ.js} +1 -1
- package/apps/web/dist/assets/{settings-menu-Dpkp3f56.js → settings-menu-Bn5-7Lqk.js} +1 -1
- package/apps/web/dist/assets/ui-s39eGOtW.js +137 -0
- package/apps/web/dist/assets/{upload-JX9BcMTq.js → upload-Bevcvx5V.js} +1 -1
- package/apps/web/dist/assets/{wrench-BSxnPs2_.js → wrench-ZD5SCsz9.js} +1 -1
- package/apps/web/dist/index.html +3 -3
- package/apps/web/dist/mcp/flow.html +53 -53
- package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +22 -0
- package/node_modules/@engenty-wizards/shared/dist/definition.js +26 -0
- package/node_modules/@engenty-wizards/shared/dist/run.d.ts +6 -0
- package/node_modules/@engenty-wizards/shared/dist/run.js +2 -0
- package/node_modules/@engenty-wizards/shared/dist/sharing.d.ts +135 -0
- package/node_modules/@engenty-wizards/shared/dist/sharing.js +11 -0
- package/node_modules/@engenty-wizards/shared/dist/store.d.ts +8 -0
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/apps/runtime/dist/docs/dev/wizard-format.md +0 -125
- package/apps/web/dist/assets/AboutDialog-CPRAlD2H.js +0 -1
- package/apps/web/dist/assets/ChatView-DVeERQdi.js +0 -10
- package/apps/web/dist/assets/EditorPage-DhUoKcss.js +0 -4
- package/apps/web/dist/assets/NewWizardChannels-Ba3XGpro.js +0 -1
- package/apps/web/dist/assets/NewWizardPage-C2Oh2sMM.js +0 -7
- package/apps/web/dist/assets/OnboardingPage-D_4ShWrL.js +0 -1
- package/apps/web/dist/assets/ProjectPage-C58rq50b.js +0 -1
- package/apps/web/dist/assets/PublicRunner-CE0ohzyd.js +0 -1
- package/apps/web/dist/assets/RunDrawer-Cd8-qHV7.js +0 -4
- package/apps/web/dist/assets/SettingsPage-ChYLv-MG.js +0 -1
- package/apps/web/dist/assets/SetupPage-hX7deF0g.js +0 -1
- package/apps/web/dist/assets/changelog-Dn9LT4MS.js +0 -1
- package/apps/web/dist/assets/index-4jH2mkp7.js +0 -14
- package/apps/web/dist/assets/index-BwW652E-.css +0 -1
- package/apps/web/dist/assets/ui-DMmT-cSO.js +0 -137
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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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`);
|