@xeplr/actions 1.1.1 → 1.1.6
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/lib/builtins/db/fetch.js +34 -5
- package/lib/builtins/db/move.js +691 -0
- package/lib/builtins/db/procedure.js +210 -0
- package/lib/builtins/db/push.js +16 -6
- package/lib/builtins/email/delete.js +42 -0
- package/lib/builtins/email/download-attachments.js +52 -0
- package/lib/builtins/email/download-email.js +45 -0
- package/lib/builtins/email/move.js +45 -0
- package/lib/builtins/email/read.js +128 -0
- package/lib/builtins/file/upload.js +6 -6
- package/lib/builtins/index.js +24 -1
- package/lib/builtins/send-email.js +114 -5
- package/lib/drivers/db/duckdb.js +646 -0
- package/lib/drivers/db/index.js +4 -1
- package/lib/drivers/db/mssql.js +32 -3
- package/lib/drivers/db/mysql.js +64 -4
- package/lib/drivers/db/postgres.js +35 -2
- package/lib/drivers/db/procedure.js +222 -0
- package/lib/drivers/email/config.js +122 -0
- package/lib/drivers/email/files.js +76 -0
- package/lib/drivers/email/imap.js +395 -0
- package/lib/drivers/email/index.js +45 -0
- package/lib/drivers/email/smtp.js +214 -0
- package/lib/runner.js +46 -1
- package/lib/streaming/spool.js +62 -2
- package/lib/uploader/attach.js +53 -7
- package/lib/uploader/index.js +84 -10
- package/lib/uploader/meta-store-knex.js +34 -5
- package/lib/uploader/meta-store-noop.js +1 -0
- package/lib/uploader/reconcile.js +12 -1
- package/package.json +22 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// db-procedure action — CALL a stored procedure. That is the whole job.
|
|
2
|
+
//
|
|
3
|
+
// It exists because "call this procedure" was previously only reachable
|
|
4
|
+
// through db-move, whose shape is "read rows from somewhere and write them
|
|
5
|
+
// somewhere else". That made a procedure returning nothing — a nightly
|
|
6
|
+
// recalculation, a rebuild, a queue drained, a flag flipped — impossible to
|
|
7
|
+
// run at all, because db-move requires a targetTable to put results in. The
|
|
8
|
+
// workaround was to name a scratch table and hope the procedure returned no
|
|
9
|
+
// rows, which is a costume, not a feature.
|
|
10
|
+
//
|
|
11
|
+
// Rows come back when there are rows. They are the OUTPUT of the action, not
|
|
12
|
+
// a movement: a caller that wants them written to a table already has db-move
|
|
13
|
+
// for exactly that, and db-move now calls the same builder this does (see
|
|
14
|
+
// drivers/db/procedure.js) rather than keeping its own copy of how a
|
|
15
|
+
// procedure is called in each dialect.
|
|
16
|
+
//
|
|
17
|
+
// ── returnsRows, and why it is not guesswork ─────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// On Postgres, "a procedure" is two different objects: a PROCEDURE (CALL, no
|
|
20
|
+
// readable result) and a set-returning FUNCTION (SELECT * FROM fn(), rows).
|
|
21
|
+
// Nothing about the name says which one you have, and calling the wrong form
|
|
22
|
+
// is an error rather than an empty result — so the caller says. The default is
|
|
23
|
+
// TRUE because a step that reads is the more common one and the more likely
|
|
24
|
+
// to be built by someone who has not thought about the distinction; a
|
|
25
|
+
// side-effect-only procedure is a deliberate act and can afford a checkbox.
|
|
26
|
+
//
|
|
27
|
+
// MSSQL's EXEC and MySQL's CALL cover both cases with one form, so the flag
|
|
28
|
+
// only changes what Postgres emits.
|
|
29
|
+
|
|
30
|
+
var dbDrivers = require('../../drivers/db');
|
|
31
|
+
var { buildProcedureCall } = require('../../drivers/db/procedure');
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
name: 'db-procedure',
|
|
35
|
+
description: 'Call a stored procedure with parameters. Returns its rows when it produces any — ' +
|
|
36
|
+
'no target table, no movement. For a procedure whose rows you want written to a ' +
|
|
37
|
+
'table, use db-move with mode="procedure".',
|
|
38
|
+
requires: [], // per-driver requires checked at runtime once dbType is known
|
|
39
|
+
|
|
40
|
+
inputSchema: [
|
|
41
|
+
// ── WHICH DATABASE, picked rather than typed ────────────────────────
|
|
42
|
+
//
|
|
43
|
+
// Same shape as db-fetch and db-push, and for the same reason: this action
|
|
44
|
+
// is meant to be SCHEDULED, and a job's inputs are fetched by the browser
|
|
45
|
+
// on every list load and snapshotted into every occurrence row. A password
|
|
46
|
+
// in there is a password everywhere, forever.
|
|
47
|
+
//
|
|
48
|
+
// So the form collects ids. @xeplr/jobs' resolveInputs swaps any
|
|
49
|
+
// *ConnectionInfoId for real credentials server-side, immediately before
|
|
50
|
+
// execute() runs, by a naming convention it applies generically
|
|
51
|
+
// (connectionInfoId → connection) — no per-action wiring.
|
|
52
|
+
{ name: 'connectionInfoId', type: 'string', order: 1,
|
|
53
|
+
optionsFrom: 'connections',
|
|
54
|
+
description: 'A saved connection. Resolved to credentials server-side.' },
|
|
55
|
+
{ name: 'dbInfoId', type: 'string', order: 2,
|
|
56
|
+
optionsFrom: 'databases', dependsOn: 'connectionInfoId',
|
|
57
|
+
description: 'Which database on that connection.' },
|
|
58
|
+
|
|
59
|
+
// Filled by the host from the two ids above, or supplied literally by a
|
|
60
|
+
// caller with no host at all — a script, a test, a standalone worker, and
|
|
61
|
+
// the live probes in this repo. `system: true` keeps them out of the form
|
|
62
|
+
// either way, but they must stay DECLARED: @xeplr/schema-handler drops any
|
|
63
|
+
// field a schema does not mention, which would strip the resolved
|
|
64
|
+
// connection on its way to execute().
|
|
65
|
+
{ name: 'dbType', type: 'string', required: true, default: 'postgres', order: 3,
|
|
66
|
+
system: true, options: ['postgres', 'mysql', 'mssql'],
|
|
67
|
+
description: 'Taken from the connection when one is chosen.' },
|
|
68
|
+
{ name: 'connection', type: 'object', required: true, order: 4,
|
|
69
|
+
system: true,
|
|
70
|
+
description: 'Connection config: { host, port, user, password, database }' },
|
|
71
|
+
|
|
72
|
+
{ name: 'procedure', type: 'string', required: true, order: 5,
|
|
73
|
+
optionsFrom: 'procedures', dependsOn: 'connectionInfoId',
|
|
74
|
+
description: 'Procedure name, optionally schema-qualified ("dbo.usp_rebuild").' },
|
|
75
|
+
// ── INCREMENTAL ─────────────────────────────────────────────────────
|
|
76
|
+
//
|
|
77
|
+
// The window this run covers, handed to the procedure as two of its own
|
|
78
|
+
// parameters, because a procedure has no WHERE to filter from outside.
|
|
79
|
+
//
|
|
80
|
+
// A SCHEDULED job fills this in by itself. @xeplr/jobs' buildWindow sets
|
|
81
|
+
// `from` to the job's coveredTo — where the last SUCCESSFUL run stopped,
|
|
82
|
+
// not when it last ran — and `to` to a single pinned `now`, keeping
|
|
83
|
+
// whatever fromParam/toParam were authored here. On success it advances
|
|
84
|
+
// coveredTo to that same `to`, so a failed run leaves the coverage where
|
|
85
|
+
// it was and the next run picks up the gap by itself. Set the job's
|
|
86
|
+
// incrementalMode to "period" and name the two parameters here; nothing
|
|
87
|
+
// else is needed.
|
|
88
|
+
//
|
|
89
|
+
// Half-open, from <= x < to. The next run starts at exactly this run's
|
|
90
|
+
// `to`, so a procedure treating `to` as inclusive double-counts the
|
|
91
|
+
// boundary row every time.
|
|
92
|
+
{ name: 'window', type: 'object', order: 6, group: 'Incremental',
|
|
93
|
+
description: '{ from, to, fromParam, toParam } — the period this run covers, passed as the ' +
|
|
94
|
+
'named parameters fromParam/toParam. Filled automatically for a job with ' +
|
|
95
|
+
'incrementalMode "period"; name the two parameters and leave from/to empty. ' +
|
|
96
|
+
'A window without fromParam/toParam is refused rather than silently dropped.' },
|
|
97
|
+
|
|
98
|
+
{ name: 'params', type: 'array', order: 7,
|
|
99
|
+
description: 'Parameters, as [{ name, value }] or bare values. Named entries are matched by ' +
|
|
100
|
+
'name on postgres and mssql; MySQL has no named-argument syntax, so there they ' +
|
|
101
|
+
'are positional and must be in the order the procedure declares them.' },
|
|
102
|
+
{ name: 'returnsRows', type: 'boolean', default: true, order: 8,
|
|
103
|
+
description: 'Postgres only: true calls a set-returning FUNCTION (SELECT * FROM fn(…)), ' +
|
|
104
|
+
'false calls a PROCEDURE (CALL proc(…)) that returns nothing. Ignored for ' +
|
|
105
|
+
'mssql and mysql, whose one call form covers both.' },
|
|
106
|
+
{ name: 'maxRows', type: 'number', order: 9, group: 'Performance',
|
|
107
|
+
description: 'Refuse to buffer more than this many rows. Omitted = no limit. A procedure ' +
|
|
108
|
+
'that answers with a million rows is a movement, not a step output.' }
|
|
109
|
+
],
|
|
110
|
+
|
|
111
|
+
execute: async function(ctx) {
|
|
112
|
+
var input = ctx.input || {};
|
|
113
|
+
var system = ctx.system || {};
|
|
114
|
+
|
|
115
|
+
var driver = dbDrivers.getDriver(input.dbType);
|
|
116
|
+
if (!driver) throw new Error('db-procedure: no driver for "' + input.dbType + '"');
|
|
117
|
+
|
|
118
|
+
// A job that names a connection but reaches here without one means the
|
|
119
|
+
// host's resolver never ran. Said plainly, because the alternative is the
|
|
120
|
+
// driver failing on an undefined host three layers down.
|
|
121
|
+
if (!input.connection) {
|
|
122
|
+
throw new Error('db-procedure: no connection. Choose a saved connection, or pass ' +
|
|
123
|
+
'`connection` and `dbType` directly. (If this job names a connectionInfoId, the host ' +
|
|
124
|
+
'has no connection resolver configured — see @xeplr/jobs init().)');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
var call = buildProcedureCall({
|
|
128
|
+
driver: driver,
|
|
129
|
+
dbType: input.dbType,
|
|
130
|
+
name: input.procedure,
|
|
131
|
+
params: input.params,
|
|
132
|
+
window: input.window || null,
|
|
133
|
+
returnsRows: input.returnsRows
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// The STATEMENT, never its parameters — same rule the move path follows.
|
|
137
|
+
// A procedure's arguments can carry values out of the customer's own data
|
|
138
|
+
// (a customer id, an email), and a log is not where those belong.
|
|
139
|
+
if (system.log) {
|
|
140
|
+
system.log('Calling ' + input.procedure + ' on ' + input.dbType + ' "' + connDbName(input.connection) + '"', {
|
|
141
|
+
phase: 'call',
|
|
142
|
+
event: 'procedure_started',
|
|
143
|
+
procedure: input.procedure,
|
|
144
|
+
dbType: input.dbType,
|
|
145
|
+
database: connDbName(input.connection),
|
|
146
|
+
sql: call.sql,
|
|
147
|
+
paramCount: call.params.length,
|
|
148
|
+
// The BOUNDS, which are configuration somebody chose rather than rows
|
|
149
|
+
// out of the customer's tables — and the first thing a "why did this
|
|
150
|
+
// run do nothing" question asks about. The other parameter VALUES are
|
|
151
|
+
// deliberately absent; they can carry customer data.
|
|
152
|
+
window: input.window
|
|
153
|
+
? { from: input.window.from || null, to: input.window.to || null,
|
|
154
|
+
fromParam: input.window.fromParam || null, toParam: input.window.toParam || null }
|
|
155
|
+
: null,
|
|
156
|
+
// Said out loud because it is the one thing that silently does
|
|
157
|
+
// something other than what the author wrote — names given to a MySQL
|
|
158
|
+
// call are ignored and the order decides.
|
|
159
|
+
parametersAreNamed: call.named
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (system.log && input.dbType === 'mysql' && (input.params || []).some(function(p) { return p && p.name; })) {
|
|
163
|
+
system.log('MySQL has no named-argument syntax — these parameters are being passed in the ' +
|
|
164
|
+
'order given, not by name. Check they match the procedure\'s declaration.', {
|
|
165
|
+
phase: 'call', event: 'names_ignored', dbType: 'mysql', paramCount: call.params.length });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
var pool = await driver.connect(input.connection);
|
|
169
|
+
var startedAt = Date.now();
|
|
170
|
+
try {
|
|
171
|
+
var result = await driver.query(pool, call.sql, call.params);
|
|
172
|
+
// Every driver normalises to { rows } — and a call that returns nothing
|
|
173
|
+
// legitimately has none, which is a success, not an empty answer to
|
|
174
|
+
// apologise for.
|
|
175
|
+
var rows = (result && result.rows) || [];
|
|
176
|
+
|
|
177
|
+
if (input.maxRows && rows.length > input.maxRows) {
|
|
178
|
+
throw new Error('db-procedure: ' + input.procedure + ' returned ' + rows.length +
|
|
179
|
+
' rows, over the ' + input.maxRows + ' allowed. Use db-move to write a result this size ' +
|
|
180
|
+
'to a table instead of carrying it through a step.');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (system.log) {
|
|
184
|
+
system.log('Done — ' + input.procedure + ' returned ' + rows.length + ' row(s) in ' +
|
|
185
|
+
((Date.now() - startedAt) / 1000).toFixed(1) + 's', {
|
|
186
|
+
phase: 'summary', event: 'procedure_done',
|
|
187
|
+
procedure: input.procedure, rows: rows.length, durationMs: Date.now() - startedAt
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { rows: rows, rowCount: rows.length, procedure: input.procedure, sql: call.sql };
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (system.log) {
|
|
194
|
+
system.log(input.procedure + ' failed: ' + err.message, {
|
|
195
|
+
phase: 'summary', event: 'procedure_failed',
|
|
196
|
+
procedure: input.procedure, reason: err.message, durationMs: Date.now() - startedAt });
|
|
197
|
+
}
|
|
198
|
+
throw err;
|
|
199
|
+
} finally {
|
|
200
|
+
// Always — a pooled connection left open holds sockets and keeps the
|
|
201
|
+
// process alive, the same trap the SMTP transport had.
|
|
202
|
+
await driver.close(pool);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** The database name off a connection config — never the password. */
|
|
208
|
+
function connDbName(connection) {
|
|
209
|
+
return (connection && connection.database) || '?';
|
|
210
|
+
}
|
package/lib/builtins/db/push.js
CHANGED
|
@@ -21,9 +21,19 @@ module.exports = {
|
|
|
21
21
|
requires: [], // per-driver requires checked at runtime once dbType is known
|
|
22
22
|
|
|
23
23
|
inputSchema: [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
{ name: '
|
|
24
|
+
// See db/fetch.js for why there are two routes in and why the literal
|
|
25
|
+
// one is not for the job editor. Same contract, same reasons.
|
|
26
|
+
{ name: 'connectionInfoId', type: 'string', order: 1,
|
|
27
|
+
optionsFrom: 'connections',
|
|
28
|
+
description: 'A saved connection. Resolved to credentials server-side.' },
|
|
29
|
+
{ name: 'dbInfoId', type: 'string', order: 2,
|
|
30
|
+
optionsFrom: 'databases', dependsOn: 'connectionInfoId',
|
|
31
|
+
description: 'Which database on that connection.' },
|
|
32
|
+
{ name: 'dbType', type: 'string', required: true, default: 'postgres', order: 3,
|
|
33
|
+
system: true, options: ['postgres', 'mysql', 'mssql', 'mongo'],
|
|
34
|
+
description: 'Taken from the connection when one is chosen.' },
|
|
35
|
+
{ name: 'connection', type: 'object', required: true, order: 4,
|
|
36
|
+
system: true,
|
|
27
37
|
description: 'Connection config: { host, port, user, password, database }' },
|
|
28
38
|
{ name: 'targetTable', type: 'string', required: true, order: 3 },
|
|
29
39
|
{ name: 'primaryKeys', type: 'array', order: 4,
|
|
@@ -32,10 +42,10 @@ module.exports = {
|
|
|
32
42
|
description: 'Inline rows (mutually exclusive with filePath).' },
|
|
33
43
|
{ name: 'filePath', type: 'string', order: 6,
|
|
34
44
|
description: 'Path to an NDJSON file (one row per line). Use for large uploads.' },
|
|
35
|
-
{ name: 'movementId', type: 'string', order: 7,
|
|
45
|
+
{ name: 'movementId', type: 'string', order: 7, group: 'Advanced',
|
|
36
46
|
description: 'Correlation key for rollback. Defaults to system.occurrenceId or a fresh id.' },
|
|
37
|
-
{ name: 'batchSize', type: 'number', default: 5000, order: 8 },
|
|
38
|
-
{ name: 'concurrency', type: 'number', default: 4, order: 9 }
|
|
47
|
+
{ name: 'batchSize', type: 'number', default: 5000, order: 8, group: 'Performance' },
|
|
48
|
+
{ name: 'concurrency', type: 'number', default: 4, order: 9, group: 'Performance' }
|
|
39
49
|
],
|
|
40
50
|
|
|
41
51
|
execute: async function(ctx) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// email-delete action — permanently delete one message.
|
|
2
|
+
//
|
|
3
|
+
// PERMANENT. This flags \Deleted and EXPUNGES: the message is gone from the
|
|
4
|
+
// server, not moved to Trash, and there is nothing to undo. If what you want
|
|
5
|
+
// is the server's trash folder, that is email-move with toFolder set to it —
|
|
6
|
+
// which is reversible and is usually the right choice in an automated flow.
|
|
7
|
+
//
|
|
8
|
+
// Like every message-addressed action here, the id is folder-scoped; `folder`
|
|
9
|
+
// must name where the message currently lives.
|
|
10
|
+
|
|
11
|
+
var emailDrivers = require('../../drivers/email');
|
|
12
|
+
var emailConfig = require('../../drivers/email/config');
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
name: 'email-delete',
|
|
16
|
+
description: 'Permanently delete a message (flags \\Deleted and expunges — NOT a move to Trash). ' +
|
|
17
|
+
'The message id is folder-scoped, so input.folder must name the folder it is in.',
|
|
18
|
+
requires: [],
|
|
19
|
+
|
|
20
|
+
// Which mailbox first — it is the only answer that changes what the rest of
|
|
21
|
+
// the form means. See send-email's schema for the showWhen/group split.
|
|
22
|
+
inputSchema: [
|
|
23
|
+
{ name: 'useCustomConnection', type: 'boolean', default: false, order: 1,
|
|
24
|
+
description: 'Off: use the mailbox this install is already configured with. On: supply your own below.' },
|
|
25
|
+
{ name: 'connection', type: 'object', order: 2,
|
|
26
|
+
showWhen: { field: 'useCustomConnection', equals: true },
|
|
27
|
+
description: '{ host, port, secure, user, password, tls? }' },
|
|
28
|
+
{ name: 'messageId', type: 'string', required: true, order: 3,
|
|
29
|
+
description: 'Message id (IMAP UID) as returned by email-read.' },
|
|
30
|
+
{ name: 'folder', type: 'string', default: 'INBOX', order: 4,
|
|
31
|
+
description: 'Folder the message is currently in — where its id is valid.' }
|
|
32
|
+
],
|
|
33
|
+
|
|
34
|
+
execute: async function(ctx) {
|
|
35
|
+
var input = ctx.input || {};
|
|
36
|
+
var provider = emailDrivers.getInboundProvider(input.emailType);
|
|
37
|
+
// 'system' resolves from env; 'custom' takes the step's own connection.
|
|
38
|
+
var conn = emailConfig.resolveInbound(input);
|
|
39
|
+
provider.checkRequires('email-delete', { needsParser: false });
|
|
40
|
+
return provider.delete(conn, input);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// email-download-attachments action — save a message's attachments to disk,
|
|
2
|
+
// optionally filtered by extension and/or filename substring.
|
|
3
|
+
//
|
|
4
|
+
// Each file is written with a random prefix, because attachment names collide
|
|
5
|
+
// constantly ("invoice.pdf" from fifty senders) and a download that silently
|
|
6
|
+
// overwrites the previous one is expensive to notice. Names are also
|
|
7
|
+
// sanitized: a filename arriving from a mailbox is attacker-controlled, and
|
|
8
|
+
// one containing path separators would otherwise escape the output directory.
|
|
9
|
+
//
|
|
10
|
+
// `saved[]` entries carry { filename, path } — exactly nodemailer's attachment
|
|
11
|
+
// descriptor shape — so the output can be handed straight to email-send to
|
|
12
|
+
// forward what was just downloaded.
|
|
13
|
+
|
|
14
|
+
var emailDrivers = require('../../drivers/email');
|
|
15
|
+
var emailConfig = require('../../drivers/email/config');
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
name: 'email-download-attachments',
|
|
19
|
+
description: 'Save a message\'s attachments to disk, optionally filtered by extension or ' +
|
|
20
|
+
'filename substring. Returns { count, saved[], path, skipped }.',
|
|
21
|
+
requires: [],
|
|
22
|
+
|
|
23
|
+
// Which mailbox first — it is the only answer that changes what the rest of
|
|
24
|
+
// the form means. See send-email's schema for the showWhen/group split.
|
|
25
|
+
inputSchema: [
|
|
26
|
+
{ name: 'useCustomConnection', type: 'boolean', default: false, order: 1,
|
|
27
|
+
description: 'Off: use the mailbox this install is already configured with. On: supply your own below.' },
|
|
28
|
+
{ name: 'connection', type: 'object', order: 2,
|
|
29
|
+
showWhen: { field: 'useCustomConnection', equals: true },
|
|
30
|
+
description: '{ host, port, secure, user, password, tls? }' },
|
|
31
|
+
{ name: 'messageId', type: 'string', required: true, order: 3,
|
|
32
|
+
description: 'Message id (IMAP UID) as returned by email-read.' },
|
|
33
|
+
{ name: 'folder', type: 'string', default: 'INBOX', order: 4,
|
|
34
|
+
description: 'Folder the message is currently in — where its id is valid.' },
|
|
35
|
+
{ name: 'outDir', type: 'string', order: 5,
|
|
36
|
+
description: 'Directory to write into. Defaults to XEPLR_ACTIONS_TMP_DIR.' },
|
|
37
|
+
|
|
38
|
+
{ name: 'extension', type: 'string', order: 6, group: 'Filters',
|
|
39
|
+
description: 'Only attachments with this extension, e.g. "pdf" or ".pdf".' },
|
|
40
|
+
{ name: 'filenameContains', type: 'string', order: 7, group: 'Filters',
|
|
41
|
+
description: 'Only attachments whose filename contains this substring.' }
|
|
42
|
+
],
|
|
43
|
+
|
|
44
|
+
execute: async function(ctx) {
|
|
45
|
+
var input = ctx.input || {};
|
|
46
|
+
var provider = emailDrivers.getInboundProvider(input.emailType);
|
|
47
|
+
// 'system' resolves from env; 'custom' takes the step's own connection.
|
|
48
|
+
var conn = emailConfig.resolveInbound(input);
|
|
49
|
+
provider.checkRequires('email-download-attachments', { needsParser: true });
|
|
50
|
+
return provider.downloadAttachments(conn, input);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// email-download-email action — save the whole message as an RFC-822 .eml.
|
|
2
|
+
//
|
|
3
|
+
// Headers, body and every attachment, byte for byte as the server holds it —
|
|
4
|
+
// which is what an .eml IS, so nothing is built: the raw IMAP source streams
|
|
5
|
+
// straight to disk without being parsed or buffered whole. A message carrying
|
|
6
|
+
// a 40 MB attachment never becomes a 40 MB string.
|
|
7
|
+
//
|
|
8
|
+
// Output shape is deliberately identical to email-download-attachments
|
|
9
|
+
// ({ count, saved[], path }) so a downstream step binds the same way whichever
|
|
10
|
+
// produced the file.
|
|
11
|
+
|
|
12
|
+
var emailDrivers = require('../../drivers/email');
|
|
13
|
+
var emailConfig = require('../../drivers/email/config');
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
name: 'email-download-email',
|
|
17
|
+
description: 'Save an entire message to disk as a .eml (RFC-822) file, streamed. ' +
|
|
18
|
+
'Returns { count, saved[], path } like email-download-attachments.',
|
|
19
|
+
requires: [],
|
|
20
|
+
|
|
21
|
+
// Which mailbox first — it is the only answer that changes what the rest of
|
|
22
|
+
// the form means. See send-email's schema for the showWhen/group split.
|
|
23
|
+
inputSchema: [
|
|
24
|
+
{ name: 'useCustomConnection', type: 'boolean', default: false, order: 1,
|
|
25
|
+
description: 'Off: use the mailbox this install is already configured with. On: supply your own below.' },
|
|
26
|
+
{ name: 'connection', type: 'object', order: 2,
|
|
27
|
+
showWhen: { field: 'useCustomConnection', equals: true },
|
|
28
|
+
description: '{ host, port, secure, user, password, tls? }' },
|
|
29
|
+
{ name: 'messageId', type: 'string', required: true, order: 3,
|
|
30
|
+
description: 'Message id (IMAP UID) as returned by email-read.' },
|
|
31
|
+
{ name: 'folder', type: 'string', default: 'INBOX', order: 4,
|
|
32
|
+
description: 'Folder the message is currently in — where its id is valid.' },
|
|
33
|
+
{ name: 'outDir', type: 'string', order: 5,
|
|
34
|
+
description: 'Directory to write into. Defaults to XEPLR_ACTIONS_TMP_DIR.' }
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
execute: async function(ctx) {
|
|
38
|
+
var input = ctx.input || {};
|
|
39
|
+
var provider = emailDrivers.getInboundProvider(input.emailType);
|
|
40
|
+
// 'system' resolves from env; 'custom' takes the step's own connection.
|
|
41
|
+
var conn = emailConfig.resolveInbound(input);
|
|
42
|
+
provider.checkRequires('email-download-email', { needsParser: false });
|
|
43
|
+
return provider.downloadEmail(conn, input);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// email-move action — move one message to another folder.
|
|
2
|
+
//
|
|
3
|
+
// `folder` (the SOURCE) is a real input, not a detail: a message is addressed
|
|
4
|
+
// by IMAP UID, which is scoped to the folder it currently sits in. Moving from
|
|
5
|
+
// anywhere other than INBOX without saying so targets the wrong mailbox and
|
|
6
|
+
// either fails or moves something else.
|
|
7
|
+
//
|
|
8
|
+
// A move CHANGES the message's uid. Any further step that addresses the same
|
|
9
|
+
// message (download, delete) must run BEFORE this one, or re-read to pick up
|
|
10
|
+
// the new uid from the destination folder.
|
|
11
|
+
|
|
12
|
+
var emailDrivers = require('../../drivers/email');
|
|
13
|
+
var emailConfig = require('../../drivers/email/config');
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
name: 'email-move',
|
|
17
|
+
description: 'Move a message to another mailbox folder. The message id is folder-scoped, ' +
|
|
18
|
+
'so input.folder must name the folder the message is currently in.',
|
|
19
|
+
requires: [],
|
|
20
|
+
|
|
21
|
+
// Which mailbox first — it is the only answer that changes what the rest of
|
|
22
|
+
// the form means. See send-email's schema for the showWhen/group split.
|
|
23
|
+
inputSchema: [
|
|
24
|
+
{ name: 'useCustomConnection', type: 'boolean', default: false, order: 1,
|
|
25
|
+
description: 'Off: use the mailbox this install is already configured with. On: supply your own below.' },
|
|
26
|
+
{ name: 'connection', type: 'object', order: 2,
|
|
27
|
+
showWhen: { field: 'useCustomConnection', equals: true },
|
|
28
|
+
description: '{ host, port, secure, user, password, tls? }' },
|
|
29
|
+
{ name: 'messageId', type: 'string', required: true, order: 3,
|
|
30
|
+
description: 'Message id (IMAP UID) as returned by email-read.' },
|
|
31
|
+
{ name: 'toFolder', type: 'string', required: true, order: 4,
|
|
32
|
+
description: 'Destination folder name.' },
|
|
33
|
+
{ name: 'folder', type: 'string', default: 'INBOX', order: 5,
|
|
34
|
+
description: 'Folder the message is currently in — where its id is valid.' }
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
execute: async function(ctx) {
|
|
38
|
+
var input = ctx.input || {};
|
|
39
|
+
var provider = emailDrivers.getInboundProvider(input.emailType);
|
|
40
|
+
// 'system' resolves from env; 'custom' takes the step's own connection.
|
|
41
|
+
var conn = emailConfig.resolveInbound(input);
|
|
42
|
+
provider.checkRequires('email-move', { needsParser: false });
|
|
43
|
+
return provider.move(conn, input);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// email-read action — list messages from a mailbox, newest first.
|
|
2
|
+
//
|
|
3
|
+
// STREAMING follows the older builtins convention (streaming_mode defaults
|
|
4
|
+
// FALSE), not db-fetch's: a mailbox read is bounded by `limit` (max 1000) and
|
|
5
|
+
// the normal case is a handful of messages a workflow branches on immediately.
|
|
6
|
+
// Set streaming_mode:true when pulling bodies in bulk — HTML mail with inline
|
|
7
|
+
// base64 images is large, and spooling keeps it off the heap and out of the
|
|
8
|
+
// run record. Temp file: XEPLR_ACTIONS_TMP_DIR, same as db-fetch.
|
|
9
|
+
//
|
|
10
|
+
// COST CONTROL is `body`, not `limit`. body:'none' skips downloading message
|
|
11
|
+
// sources entirely and fetches envelopes only — the right setting for a
|
|
12
|
+
// monitor that just needs ids to feed move/download steps.
|
|
13
|
+
|
|
14
|
+
var emailDrivers = require('../../drivers/email');
|
|
15
|
+
var emailConfig = require('../../drivers/email/config');
|
|
16
|
+
var crypto = require('crypto');
|
|
17
|
+
var os = require('os');
|
|
18
|
+
var path = require('path');
|
|
19
|
+
var fs = require('fs');
|
|
20
|
+
var fsp = require('fs/promises');
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
name: 'email-read',
|
|
24
|
+
description: 'List messages from a mailbox folder, newest first, with optional ' +
|
|
25
|
+
'unread/date/sender/subject filters. Returns message metadata and ' +
|
|
26
|
+
'(optionally) bodies. Routes to the provider named by input.emailType.',
|
|
27
|
+
requires: [], // peer deps (imapflow/mailparser) checked at runtime
|
|
28
|
+
|
|
29
|
+
// Which mailbox first — it is the only answer that changes what the rest of
|
|
30
|
+
// the form means — then what to read out of it. The four narrowing filters
|
|
31
|
+
// and the two output switches are `group`ed: real inputs, but a step that
|
|
32
|
+
// just wants "the unread ones" should not have to scroll past six boxes it
|
|
33
|
+
// is leaving empty. See send-email's schema for the showWhen/group split.
|
|
34
|
+
inputSchema: [
|
|
35
|
+
{ name: 'useCustomConnection', type: 'boolean', default: false, order: 1,
|
|
36
|
+
description: 'Off: use the mailbox this install is already configured with. On: supply your own below.' },
|
|
37
|
+
{ name: 'connection', type: 'object', order: 2,
|
|
38
|
+
showWhen: { field: 'useCustomConnection', equals: true },
|
|
39
|
+
description: '{ host, port, secure, user, password, tls? }' },
|
|
40
|
+
|
|
41
|
+
{ name: 'folder', type: 'string', default: 'INBOX', order: 3,
|
|
42
|
+
description: 'Mailbox folder to read from.' },
|
|
43
|
+
{ name: 'unread_only', type: 'boolean', default: false, order: 4,
|
|
44
|
+
description: 'Only messages without the \\Seen flag.' },
|
|
45
|
+
{ name: 'limit', type: 'number', default: 50, order: 5,
|
|
46
|
+
description: 'Max messages to return, newest first (1-1000).' },
|
|
47
|
+
|
|
48
|
+
{ name: 'since', type: 'string', order: 6, group: 'Filters',
|
|
49
|
+
description: 'ISO 8601 date — server-side IMAP SINCE filter.' },
|
|
50
|
+
{ name: 'from_equals', type: 'string', order: 7, group: 'Filters',
|
|
51
|
+
description: 'Exact sender address match (case-insensitive).' },
|
|
52
|
+
{ name: 'from_contains', type: 'string', order: 8, group: 'Filters',
|
|
53
|
+
description: 'Substring match on sender address.' },
|
|
54
|
+
{ name: 'subject_contains', type: 'string', order: 9, group: 'Filters',
|
|
55
|
+
description: 'Substring match on subject.' },
|
|
56
|
+
|
|
57
|
+
{ name: 'body', type: 'string', default: 'full', order: 10, group: 'Output',
|
|
58
|
+
description: '"full" (html+text), "text" (text only), or "none" (metadata only — much faster).' },
|
|
59
|
+
{ name: 'streaming_mode', type: 'boolean', default: false, order: 11, group: 'Output',
|
|
60
|
+
description: 'true → spool messages to an NDJSON file (output.filePath). false → inline output.messages.' }
|
|
61
|
+
],
|
|
62
|
+
|
|
63
|
+
execute: async function(ctx) {
|
|
64
|
+
var input = ctx.input || {};
|
|
65
|
+
var system = ctx.system || {};
|
|
66
|
+
|
|
67
|
+
var provider = emailDrivers.getInboundProvider(input.emailType);
|
|
68
|
+
|
|
69
|
+
// 'system' resolves from env; 'custom' takes the step's own connection.
|
|
70
|
+
|
|
71
|
+
var conn = emailConfig.resolveInbound(input);
|
|
72
|
+
provider.checkRequires('email-read', { needsParser: (input.body || 'full') !== 'none' });
|
|
73
|
+
|
|
74
|
+
var messages = await provider.read(conn, input);
|
|
75
|
+
|
|
76
|
+
if (!input.streaming_mode) {
|
|
77
|
+
return { messages: messages, messageCount: messages.length, streaming: false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
var filePath = await resolveOutputPath(system);
|
|
81
|
+
var stats = await writeNDJSON(filePath, messages);
|
|
82
|
+
return {
|
|
83
|
+
filePath: filePath,
|
|
84
|
+
format: 'jsonl',
|
|
85
|
+
bytes: stats.bytes,
|
|
86
|
+
messageCount: stats.rows,
|
|
87
|
+
streaming: true
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ─── helpers ────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
async function resolveOutputPath(system) {
|
|
95
|
+
var dir = process.env.XEPLR_ACTIONS_TMP_DIR || path.join(os.tmpdir(), 'xeplr-actions');
|
|
96
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
97
|
+
var base = (system.occurrenceId || crypto.randomBytes(6).toString('hex'))
|
|
98
|
+
+ '_email-read_' + crypto.randomBytes(6).toString('hex') + '.jsonl';
|
|
99
|
+
return path.join(dir, base);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// One JSON object per line — the same NDJSON shape db-fetch emits, so the
|
|
103
|
+
// existing readers work unchanged. Honors write backpressure rather than
|
|
104
|
+
// queueing the whole array in memory, which is the entire point of spooling.
|
|
105
|
+
async function writeNDJSON(filePath, rows) {
|
|
106
|
+
var ws = fs.createWriteStream(filePath, { encoding: 'utf8' });
|
|
107
|
+
var count = 0;
|
|
108
|
+
var bytes = 0;
|
|
109
|
+
try {
|
|
110
|
+
for (var i = 0; i < rows.length; i++) {
|
|
111
|
+
var line = JSON.stringify(rows[i]) + '\n';
|
|
112
|
+
bytes += Buffer.byteLength(line);
|
|
113
|
+
count++;
|
|
114
|
+
if (!ws.write(line)) {
|
|
115
|
+
await new Promise(function(resolve, reject) {
|
|
116
|
+
ws.once('drain', resolve);
|
|
117
|
+
ws.once('error', reject);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
await new Promise(function(resolve, reject) {
|
|
123
|
+
ws.end(resolve);
|
|
124
|
+
ws.once('error', reject);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return { rows: count, bytes: bytes };
|
|
128
|
+
}
|
|
@@ -28,25 +28,25 @@ module.exports = {
|
|
|
28
28
|
description: 'local | sftp | sharepoint | google' },
|
|
29
29
|
{ name: 'sourcePath', type: 'string', required: true, order: 2,
|
|
30
30
|
description: 'File path (local) or key/id (remote sources)' },
|
|
31
|
-
{ name: 'sourceConfig', type: 'object', default: {}, order: 3,
|
|
31
|
+
{ name: 'sourceConfig', type: 'object', default: {}, order: 3, group: 'Source & format',
|
|
32
32
|
description: 'Source-specific config (SFTP creds, SharePoint site, etc.)' },
|
|
33
33
|
|
|
34
34
|
// FORMAT
|
|
35
35
|
{ name: 'format', type: 'string', required: true, default: 'csv', order: 4,
|
|
36
36
|
description: 'csv | excel | txt | json' },
|
|
37
|
-
{ name: 'formatConfig', type: 'object', default: {}, order: 5,
|
|
37
|
+
{ name: 'formatConfig', type: 'object', default: {}, order: 5, group: 'Source & format',
|
|
38
38
|
description: 'Format-specific options (delimiter, sheet name, etc.)' },
|
|
39
39
|
|
|
40
40
|
// TARGET
|
|
41
41
|
{ name: 'dbType', type: 'string', required: true, default: 'postgres', order: 10 },
|
|
42
42
|
{ name: 'dbConnection', type: 'object', required: true, order: 11 },
|
|
43
43
|
{ name: 'targetTable', type: 'string', required: true, order: 12 },
|
|
44
|
-
{ name: 'primaryKeys', type: 'array', order: 13 },
|
|
44
|
+
{ name: 'primaryKeys', type: 'array', order: 13, group: 'Advanced' },
|
|
45
45
|
|
|
46
46
|
// CONTROL
|
|
47
|
-
{ name: 'movementId', type: 'string', order: 20 },
|
|
48
|
-
{ name: 'batchSize', type: 'number', default: 5000, order: 21 },
|
|
49
|
-
{ name: 'concurrency', type: 'number', default: 4, order: 22 }
|
|
47
|
+
{ name: 'movementId', type: 'string', order: 20, group: 'Advanced' },
|
|
48
|
+
{ name: 'batchSize', type: 'number', default: 5000, order: 21, group: 'Performance' },
|
|
49
|
+
{ name: 'concurrency', type: 'number', default: 4, order: 22, group: 'Performance' }
|
|
50
50
|
],
|
|
51
51
|
|
|
52
52
|
execute: async function(ctx) {
|
package/lib/builtins/index.js
CHANGED
|
@@ -27,6 +27,15 @@ module.exports = {
|
|
|
27
27
|
dbFetch: require('./db/fetch'),
|
|
28
28
|
dbPush: require('./db/push'),
|
|
29
29
|
dbQuery: require('./db/query'),
|
|
30
|
+
// CALLING a procedure, as opposed to moving what it returns. db-move can
|
|
31
|
+
// still read from one (mode: 'procedure') and both build the statement with
|
|
32
|
+
// drivers/db/procedure.js, so there is one answer to "how is a procedure
|
|
33
|
+
// called" rather than one per caller.
|
|
34
|
+
dbProcedure: require('./db/procedure'),
|
|
35
|
+
// One window of rows, source DB to target DB, in a single streamed pass —
|
|
36
|
+
// fetchStream piped straight into upload() rather than staged through an
|
|
37
|
+
// NDJSON file the way dbFetch → dbPush does. See db/move.js.
|
|
38
|
+
dbMove: require('./db/move'),
|
|
30
39
|
|
|
31
40
|
// DB introspection — list tables/views/procedures, or one table's columns.
|
|
32
41
|
dbListTables: require('./db/list-tables'),
|
|
@@ -36,5 +45,19 @@ module.exports = {
|
|
|
36
45
|
|
|
37
46
|
// File actions — factory-shaped for the same reason (meta store binding).
|
|
38
47
|
fileUpload: require('./file/upload'),
|
|
39
|
-
fileMove: require('./file/move')
|
|
48
|
+
fileMove: require('./file/move'),
|
|
49
|
+
|
|
50
|
+
// Email actions. Inbound routes to getInboundProvider(input.emailType)
|
|
51
|
+
// (imap today), outbound to getOutboundProvider (smtp) — see
|
|
52
|
+
// lib/drivers/email/. Peer deps (imapflow, mailparser, nodemailer) are all
|
|
53
|
+
// optional and lazy-required, so requiring this file never pulls them in.
|
|
54
|
+
//
|
|
55
|
+
// MESSAGE IDS ARE FOLDER-SCOPED. Every message-addressed action takes a
|
|
56
|
+
// `folder`, and a move CHANGES the id — so within one workflow, do the
|
|
57
|
+
// downloads and deletes first and move the message last.
|
|
58
|
+
emailRead: require('./email/read'),
|
|
59
|
+
emailMove: require('./email/move'),
|
|
60
|
+
emailDelete: require('./email/delete'),
|
|
61
|
+
emailDownloadEmail: require('./email/download-email'),
|
|
62
|
+
emailDownloadAttachments: require('./email/download-attachments')
|
|
40
63
|
};
|