@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.
@@ -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
+ }
@@ -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
- { name: 'dbType', type: 'string', required: true, default: 'postgres', order: 1,
25
- description: 'postgres | mysql | mssql | mongo' },
26
- { name: 'connection', type: 'object', required: true, order: 2,
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) {
@@ -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
  };