@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
package/lib/drivers/db/mssql.js
CHANGED
|
@@ -31,7 +31,7 @@ var TYPE_MAP = {
|
|
|
31
31
|
string: 'NVARCHAR(MAX)',
|
|
32
32
|
number: 'DECIMAL(38,10)',
|
|
33
33
|
boolean: 'BIT',
|
|
34
|
-
date: '
|
|
34
|
+
date: 'DATE',
|
|
35
35
|
datetime: 'DATETIME2',
|
|
36
36
|
object: 'NVARCHAR(MAX)',
|
|
37
37
|
array: 'NVARCHAR(MAX)'
|
|
@@ -46,9 +46,30 @@ var NATIVE_TO_LOGICAL = {
|
|
|
46
46
|
money: 'number', smallmoney: 'number',
|
|
47
47
|
bit: 'boolean',
|
|
48
48
|
datetime: 'datetime', datetime2: 'datetime', smalldatetime: 'datetime',
|
|
49
|
-
|
|
49
|
+
// 'date' maps to its OWN logical type — see reconcile.js.
|
|
50
|
+
date: 'date', time: 'datetime', datetimeoffset: 'datetime'
|
|
50
51
|
};
|
|
51
52
|
|
|
53
|
+
|
|
54
|
+
// A calendar date, as YYYY-MM-DD, with NO timezone conversion.
|
|
55
|
+
//
|
|
56
|
+
// toISOString() would convert to UTC first, which is exactly the bug: a Date
|
|
57
|
+
// holding 2026-01-05 local becomes 2026-01-04 in UTC anywhere east of
|
|
58
|
+
// Greenwich. The local getters are the right ones here precisely because a
|
|
59
|
+
// business date has no instant to convert.
|
|
60
|
+
function toDateOnly(v) {
|
|
61
|
+
if (v === null || v === undefined) return v;
|
|
62
|
+
if (v instanceof Date) {
|
|
63
|
+
if (isNaN(v.getTime())) return null;
|
|
64
|
+
return v.getFullYear() + '-' +
|
|
65
|
+
String(v.getMonth() + 1).padStart(2, '0') + '-' +
|
|
66
|
+
String(v.getDate()).padStart(2, '0');
|
|
67
|
+
}
|
|
68
|
+
// Already a date string — take the date part and leave the rest alone.
|
|
69
|
+
var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v));
|
|
70
|
+
return m ? m[1] : v;
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
var INTERNAL_ID_COL = '__xeplr_id__';
|
|
53
74
|
var INTERNAL_MOVEMENT_COL = '__xeplr_movement_id__';
|
|
54
75
|
|
|
@@ -92,7 +113,13 @@ async function query(pool, sql, params) {
|
|
|
92
113
|
var rowCount = Array.isArray(r.rowsAffected)
|
|
93
114
|
? r.rowsAffected.reduce(function(a, b) { return a + b; }, 0)
|
|
94
115
|
: rows.length;
|
|
95
|
-
|
|
116
|
+
// recordset.columns is present on an EMPTY recordset too — mssql describes
|
|
117
|
+
// the result's shape independently of how many rows matched, which is
|
|
118
|
+
// exactly what a caller listing a query's columns needs. It's keyed by name
|
|
119
|
+
// with an `index`, so sort by that to keep the SELECT's own order.
|
|
120
|
+
var meta = (r.recordset && r.recordset.columns) || {};
|
|
121
|
+
var columns = Object.keys(meta).sort(function(a, b) { return meta[a].index - meta[b].index; });
|
|
122
|
+
return { rows: rows, rowCount: rowCount, columns: columns };
|
|
96
123
|
}
|
|
97
124
|
|
|
98
125
|
function toMssqlType(type) { return TYPE_MAP[type] || 'NVARCHAR(MAX)'; }
|
|
@@ -257,6 +284,8 @@ function coerceValue(v, type) {
|
|
|
257
284
|
return v; // let SQL Server reject
|
|
258
285
|
|
|
259
286
|
case 'date':
|
|
287
|
+
return toDateOnly(v);
|
|
288
|
+
|
|
260
289
|
case 'datetime':
|
|
261
290
|
return toMssqlDatetime(v);
|
|
262
291
|
|
package/lib/drivers/db/mysql.js
CHANGED
|
@@ -28,7 +28,7 @@ var TYPE_MAP = {
|
|
|
28
28
|
string: 'LONGTEXT',
|
|
29
29
|
number: 'DECIMAL(38,10)',
|
|
30
30
|
boolean: 'TINYINT(1)',
|
|
31
|
-
date: '
|
|
31
|
+
date: 'DATE',
|
|
32
32
|
datetime: 'DATETIME(6)',
|
|
33
33
|
object: 'JSON',
|
|
34
34
|
array: 'JSON'
|
|
@@ -41,11 +41,33 @@ var NATIVE_TO_LOGICAL = {
|
|
|
41
41
|
int: 'number', integer: 'number', bigint: 'number', smallint: 'number',
|
|
42
42
|
mediumint: 'number', tinyint: 'number', decimal: 'number', numeric: 'number',
|
|
43
43
|
float: 'number', double: 'number', bit: 'boolean',
|
|
44
|
-
|
|
44
|
+
// 'date' maps to its OWN logical type — see reconcile.js. A DATE column
|
|
45
|
+
// created as DATETIME acquires a midnight that shifts under a timezone.
|
|
46
|
+
date: 'date', datetime: 'datetime', timestamp: 'datetime',
|
|
45
47
|
time: 'datetime', year: 'datetime',
|
|
46
48
|
json: 'object'
|
|
47
49
|
};
|
|
48
50
|
|
|
51
|
+
|
|
52
|
+
// A calendar date, as YYYY-MM-DD, with NO timezone conversion.
|
|
53
|
+
//
|
|
54
|
+
// toISOString() would convert to UTC first, which is exactly the bug: a Date
|
|
55
|
+
// holding 2026-01-05 local becomes 2026-01-04 in UTC anywhere east of
|
|
56
|
+
// Greenwich. The local getters are the right ones here precisely because a
|
|
57
|
+
// business date has no instant to convert.
|
|
58
|
+
function toDateOnly(v) {
|
|
59
|
+
if (v === null || v === undefined) return v;
|
|
60
|
+
if (v instanceof Date) {
|
|
61
|
+
if (isNaN(v.getTime())) return null;
|
|
62
|
+
return v.getFullYear() + '-' +
|
|
63
|
+
String(v.getMonth() + 1).padStart(2, '0') + '-' +
|
|
64
|
+
String(v.getDate()).padStart(2, '0');
|
|
65
|
+
}
|
|
66
|
+
// Already a date string — take the date part and leave the rest alone.
|
|
67
|
+
var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v));
|
|
68
|
+
return m ? m[1] : v;
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
var INTERNAL_ID_COL = '__xeplr_id__';
|
|
50
72
|
var INTERNAL_MOVEMENT_COL = '__xeplr_movement_id__';
|
|
51
73
|
|
|
@@ -77,13 +99,49 @@ async function close(pool) {
|
|
|
77
99
|
}
|
|
78
100
|
|
|
79
101
|
// Normalize mysql2's `[rows|ResultSetHeader, fields]` to Postgres shape.
|
|
102
|
+
//
|
|
103
|
+
// The second element is the field list, which mysql2 returns even for a
|
|
104
|
+
// SELECT that matched nothing — so `columns` describes the result's shape
|
|
105
|
+
// when there is not a single row to infer it from.
|
|
80
106
|
async function query(pool, sql, params) {
|
|
81
107
|
var res = await pool.query(sql, params || []);
|
|
82
108
|
var out = res[0];
|
|
109
|
+
var fields = res[1];
|
|
110
|
+
|
|
111
|
+
// A CALL ANSWERS WITH MULTIPLE RESULT SETS, and that is not an edge case —
|
|
112
|
+
// it is what every stored procedure that SELECTs does. mysql2 returns
|
|
113
|
+
// `[ [rows], OkPacket ]` for one, so `out` is an array whose first element
|
|
114
|
+
// is itself an array, and `fields` is `[ [fields], undefined ]`.
|
|
115
|
+
//
|
|
116
|
+
// Read as a plain SELECT that produced: rows = [ [ {...} ], OkPacket ] — a
|
|
117
|
+
// result set wrapped in another one — and columns mapped `f.name` over that
|
|
118
|
+
// undefined second entry, which threw "Cannot read properties of undefined
|
|
119
|
+
// (reading 'name')". So a procedure returning rows failed on MySQL with an
|
|
120
|
+
// error naming nothing to do with procedures. Nothing hit it before because
|
|
121
|
+
// nothing could call one: db-move's procedure mode is only exercised against
|
|
122
|
+
// mssql/postgres in practice, and there was no other caller.
|
|
123
|
+
//
|
|
124
|
+
// THE FIRST result set is the answer. A procedure that SELECTs twice is
|
|
125
|
+
// telling the caller two things and this action's shape has room for one;
|
|
126
|
+
// taking the first is the same rule every client library applies, rather
|
|
127
|
+
// than concatenating rows of different widths into one nonsense list.
|
|
128
|
+
if (Array.isArray(out) && out.length && Array.isArray(out[0])) {
|
|
129
|
+
var rows = out[0];
|
|
130
|
+
var firstFields = (Array.isArray(fields) && fields[0]) || [];
|
|
131
|
+
return {
|
|
132
|
+
rows: rows,
|
|
133
|
+
rowCount: rows.length,
|
|
134
|
+
columns: firstFields.map(function(f) { return f.name; })
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
var columns = (fields || []).map(function(f) { return f.name; });
|
|
83
139
|
if (Array.isArray(out)) {
|
|
84
|
-
return { rows: out, rowCount: out.length };
|
|
140
|
+
return { rows: out, rowCount: out.length, columns: columns }; // SELECT
|
|
85
141
|
}
|
|
86
|
-
|
|
142
|
+
// DML, and a CALL whose procedure returns nothing at all — both answer with
|
|
143
|
+
// an OkPacket rather than a result set.
|
|
144
|
+
return { rows: [], rowCount: (out && out.affectedRows) || 0, columns: [] };
|
|
87
145
|
}
|
|
88
146
|
|
|
89
147
|
function toMysqlType(type) { return TYPE_MAP[type] || 'LONGTEXT'; }
|
|
@@ -215,6 +273,8 @@ function coerceValue(v, type) {
|
|
|
215
273
|
return v; // let MySQL reject
|
|
216
274
|
|
|
217
275
|
case 'date':
|
|
276
|
+
return toDateOnly(v);
|
|
277
|
+
|
|
218
278
|
case 'datetime':
|
|
219
279
|
return toMysqlDatetime(v);
|
|
220
280
|
|
|
@@ -31,7 +31,7 @@ var TYPE_MAP = {
|
|
|
31
31
|
string: 'TEXT',
|
|
32
32
|
number: 'NUMERIC',
|
|
33
33
|
boolean: 'BOOLEAN',
|
|
34
|
-
date: '
|
|
34
|
+
date: 'DATE',
|
|
35
35
|
datetime: 'TIMESTAMPTZ',
|
|
36
36
|
object: 'JSONB',
|
|
37
37
|
array: 'JSONB'
|
|
@@ -58,12 +58,42 @@ async function close(pool) {
|
|
|
58
58
|
if (pool) await pool.end();
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// `columns` is the query's OUTPUT COLUMNS, which the server describes whether
|
|
62
|
+
// or not any row matched. Callers that need the shape of a result — a report
|
|
63
|
+
// listing its columns, say — must not have to infer it from the first row,
|
|
64
|
+
// because a query returning nothing still has columns.
|
|
65
|
+
//
|
|
66
|
+
// Attached to pg's own Result rather than wrapping it: callers already treat
|
|
67
|
+
// this as the canonical shape the other drivers normalize TO, so replacing
|
|
68
|
+
// the object risks dropping a property something else reads.
|
|
61
69
|
async function query(pool, sql, params) {
|
|
62
|
-
|
|
70
|
+
var result = await pool.query(sql, params || []);
|
|
71
|
+
result.columns = (result.fields || []).map(function(f) { return f.name; });
|
|
72
|
+
return result;
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
function toPgType(type) { return TYPE_MAP[type] || 'TEXT'; }
|
|
66
76
|
|
|
77
|
+
// A calendar date, as YYYY-MM-DD, with NO timezone conversion.
|
|
78
|
+
//
|
|
79
|
+
// toISOString() would convert to UTC first, which is exactly the bug: a Date
|
|
80
|
+
// holding 2026-01-05 local becomes 2026-01-04 in UTC anywhere east of
|
|
81
|
+
// Greenwich. The local getters are the right ones here precisely because a
|
|
82
|
+
// business date has no instant to convert.
|
|
83
|
+
function toDateOnly(v) {
|
|
84
|
+
if (v === null || v === undefined) return v;
|
|
85
|
+
if (v instanceof Date) {
|
|
86
|
+
if (isNaN(v.getTime())) return null;
|
|
87
|
+
return v.getFullYear() + '-' +
|
|
88
|
+
String(v.getMonth() + 1).padStart(2, '0') + '-' +
|
|
89
|
+
String(v.getDate()).padStart(2, '0');
|
|
90
|
+
}
|
|
91
|
+
// Already a date string — take the date part and leave the rest alone.
|
|
92
|
+
var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v));
|
|
93
|
+
return m ? m[1] : v;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
67
97
|
// Double-quote a Postgres identifier, escaping embedded quotes.
|
|
68
98
|
function quoteIdent(name) {
|
|
69
99
|
return '"' + String(name).replace(/"/g, '""') + '"';
|
|
@@ -193,6 +223,9 @@ function coerceValue(v, type) {
|
|
|
193
223
|
if (typeof v === 'number') return v !== 0;
|
|
194
224
|
return v; // let PG reject
|
|
195
225
|
|
|
226
|
+
case 'date':
|
|
227
|
+
return toDateOnly(v);
|
|
228
|
+
|
|
196
229
|
case 'datetime':
|
|
197
230
|
if (v instanceof Date) return v.toISOString();
|
|
198
231
|
return v; // strings pass through; PG parses
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// CALLING A STORED PROCEDURE — one implementation, three dialects.
|
|
2
|
+
//
|
|
3
|
+
// This is the only place in the codebase that knows how a procedure is called.
|
|
4
|
+
// It lived inside db-move's procedurePlan, which was the wrong home for two
|
|
5
|
+
// reasons and both of them showed:
|
|
6
|
+
//
|
|
7
|
+
// 1. It could only be reached by MOVING data. A procedure that returns
|
|
8
|
+
// nothing — a nightly recalculation, a flag flipped, a queue drained —
|
|
9
|
+
// had no way to be called at all, because db-move requires a target
|
|
10
|
+
// table and its whole shape is "read rows, write them somewhere".
|
|
11
|
+
// 2. A second caller would have had to copy it. Dialect-specific call
|
|
12
|
+
// syntax copied twice is dialect-specific call syntax that disagrees with
|
|
13
|
+
// itself within a release.
|
|
14
|
+
//
|
|
15
|
+
// So the mover now asks this for its SQL like anybody else. db-procedure (the
|
|
16
|
+
// action) and db-move (the mover) build the identical statement, because it is
|
|
17
|
+
// the identical function.
|
|
18
|
+
//
|
|
19
|
+
// ── the dialects genuinely differ, and not only in spelling ──────────────
|
|
20
|
+
//
|
|
21
|
+
// mssql EXEC dbo.usp_x @region = ?, @minimum = ?
|
|
22
|
+
// Returns its result set directly, and works whether or not there
|
|
23
|
+
// is one. The only dialect where one form covers both cases.
|
|
24
|
+
//
|
|
25
|
+
// mysql CALL usp_x(?, ?)
|
|
26
|
+
// No named-argument syntax exists, so parameters are POSITIONAL
|
|
27
|
+
// and must be given in the order the procedure declares them. A
|
|
28
|
+
// caller that names them is not wrong here, it is just not helped.
|
|
29
|
+
//
|
|
30
|
+
// postgres SELECT * FROM fn(region => ?) ← when you want the rows
|
|
31
|
+
// CALL proc(region => ?) ← when you do not
|
|
32
|
+
// These are two different database objects. A Postgres PROCEDURE
|
|
33
|
+
// (CREATE PROCEDURE, called with CALL) returns nothing a client
|
|
34
|
+
// can read; a set-returning FUNCTION is the thing that behaves
|
|
35
|
+
// like "a procedure that gives you rows". Choosing between them is
|
|
36
|
+
// what `returnsRows` is for, and it is why db-move can only ever
|
|
37
|
+
// use the function form: it exists to move the rows.
|
|
38
|
+
//
|
|
39
|
+
// Parameters are always BOUND, never interpolated. A procedure name is an
|
|
40
|
+
// identifier and is quoted; its arguments are values and travel as binds.
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Dialect placeholder for bind param n (0-based).
|
|
44
|
+
*
|
|
45
|
+
* Exported because the caller has to build the params array in the same order
|
|
46
|
+
* as the marks, and there is no reason for two definitions of `$1`.
|
|
47
|
+
*/
|
|
48
|
+
function placeholders(dbType) {
|
|
49
|
+
if (dbType === 'mysql') return function() { return '?'; };
|
|
50
|
+
if (dbType === 'mssql') return function(n) { return '@p' + n; };
|
|
51
|
+
return function(n) { return '$' + (n + 1); }; // postgres
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A procedure/table-function name can be schema-qualified ("dbo.usp_x") —
|
|
56
|
+
* driver.quoteIdent treats a dot as part of a single identifier and would
|
|
57
|
+
* bracket the whole string as one name ("[dbo.usp_x]"), which the database
|
|
58
|
+
* then cannot resolve at all. Table/column names elsewhere never legitimately
|
|
59
|
+
* contain a dot, which is why this is its own function rather than a change to
|
|
60
|
+
* quoteIdent itself — that one stays exactly as narrow as it always was for
|
|
61
|
+
* the callers that rely on a dot never being special.
|
|
62
|
+
*/
|
|
63
|
+
function quoteQualifiedIdent(driver, name) {
|
|
64
|
+
return String(name).split('.').map(function(part) { return driver.quoteIdent(part); }).join('.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A parameter value, made safe to bind — or a clear error instead of a
|
|
69
|
+
* cryptic one from three layers down (the mssql driver's own type inference
|
|
70
|
+
* falls back to NVARCHAR for anything it doesn't recognize as a number,
|
|
71
|
+
* boolean or date, and NVARCHAR's own validation then rejects a plain object
|
|
72
|
+
* with "Validation failed for parameter 'pN'. Invalid string." — naming the
|
|
73
|
+
* placeholder, not the parameter someone actually typed, and giving no hint
|
|
74
|
+
* that the real problem is the value's shape).
|
|
75
|
+
*
|
|
76
|
+
* A numeric-looking string ('2026') is coerced to a real number rather than
|
|
77
|
+
* left for the database to implicitly convert from NVARCHAR.
|
|
78
|
+
*
|
|
79
|
+
* AN EMPTY STRING IS NULL, and that rule is load-bearing rather than tidy: a
|
|
80
|
+
* step editor renders every unset field as '', so a procedure's optional date
|
|
81
|
+
* parameter left blank arrives here as an empty string. Passing that through
|
|
82
|
+
* fails on a date or an int in all three dialects, and "" is not what the
|
|
83
|
+
* person who left the box alone meant — they meant nothing.
|
|
84
|
+
*/
|
|
85
|
+
function normalizeParamValue(name, value) {
|
|
86
|
+
if (value === undefined || value === '') return null;
|
|
87
|
+
if (value === null || typeof value === 'number' || typeof value === 'boolean' || value instanceof Date) {
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
if (typeof value === 'string') {
|
|
91
|
+
if (/^-?\d+$/.test(value)) return parseInt(value, 10);
|
|
92
|
+
if (/^-?\d+\.\d+$/.test(value)) return parseFloat(value);
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
throw new Error('parameter "' + name + '" must be text, a number, or a date — got ' + typeof value + '.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Normalise the caller's parameter list.
|
|
100
|
+
*
|
|
101
|
+
* Accepts `[{ name, value }]` or bare values, mixed. Named entries are matched
|
|
102
|
+
* by NAME where the dialect supports it, because getting a procedure's
|
|
103
|
+
* declared parameter ORDER right from outside it is easy to get wrong
|
|
104
|
+
* silently — the call succeeds and the arguments are in the wrong slots.
|
|
105
|
+
*/
|
|
106
|
+
function normalizeParams(params) {
|
|
107
|
+
return (params || []).map(function(p) {
|
|
108
|
+
var raw = (p && typeof p === 'object' && 'value' in p) ? p : { name: null, value: p };
|
|
109
|
+
return { name: raw.name || null, value: normalizeParamValue(raw.name || '(unnamed)', raw.value) };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A WINDOW, handed to a procedure as its own parameters.
|
|
115
|
+
*
|
|
116
|
+
* Everywhere else in this codebase a window becomes a WHERE. A procedure has
|
|
117
|
+
* no WHERE to add one to — `SELECT … FROM (EXEC p) x` is not a thing in any of
|
|
118
|
+
* the three dialects — so the only way to narrow one is to pass the edges in
|
|
119
|
+
* and let the procedure do it. Which parameters those are is knowledge only
|
|
120
|
+
* the caller has, so the caller names them.
|
|
121
|
+
*
|
|
122
|
+
* REFUSED when they are not named, rather than quietly dropping the window.
|
|
123
|
+
* Dropping it means the procedure returns everything it has, the run reports
|
|
124
|
+
* success, and an incremental job silently reprocesses its whole history every
|
|
125
|
+
* night — which costs exactly what incremental was meant to save while looking
|
|
126
|
+
* like it worked.
|
|
127
|
+
*
|
|
128
|
+
* HALF-OPEN, from <= x < to, and the procedure is expected to honour that.
|
|
129
|
+
* Jobs pins `to` once per run and starts the next run at exactly that value
|
|
130
|
+
* (see @xeplr/jobs' buildWindow and migrations/0006) — so a procedure that
|
|
131
|
+
* treats `to` as inclusive double-counts the boundary row on every run.
|
|
132
|
+
*
|
|
133
|
+
* A null edge is passed through as null rather than skipped: an unbounded
|
|
134
|
+
* lower edge is what a first incremental run legitimately has, and a
|
|
135
|
+
* procedure that receives NULL can decide what that means. Skipping the
|
|
136
|
+
* parameter instead would shift every later argument by one on MySQL, where
|
|
137
|
+
* they are positional.
|
|
138
|
+
*/
|
|
139
|
+
function windowParams(win) {
|
|
140
|
+
if (!win) return [];
|
|
141
|
+
if (!win.fromParam && !win.toParam) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
'a window on a procedure needs window.fromParam / window.toParam — a procedure cannot ' +
|
|
144
|
+
'be filtered from outside. Give the procedure its own date parameters, or drop the window.');
|
|
145
|
+
}
|
|
146
|
+
var out = [];
|
|
147
|
+
if (win.fromParam) out.push({ name: win.fromParam, value: win.from == null ? null : win.from });
|
|
148
|
+
if (win.toParam) out.push({ name: win.toParam, value: win.to == null ? null : win.to });
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build the call.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} opts
|
|
156
|
+
* @param {object} opts.driver - the db driver, for quoting
|
|
157
|
+
* @param {string} opts.dbType - postgres | mysql | mssql
|
|
158
|
+
* @param {string} opts.name - procedure name, optionally schema-qualified
|
|
159
|
+
* @param {Array} [opts.params] - [{ name, value }] or bare values
|
|
160
|
+
* @param {object} [opts.window] - { from, to, fromParam, toParam } — appended
|
|
161
|
+
* as two more named parameters, AFTER the
|
|
162
|
+
* caller's own, which is what keeps a MySQL
|
|
163
|
+
* positional call in the order the author
|
|
164
|
+
* wrote it plus the window at the end.
|
|
165
|
+
* @param {boolean} [opts.returnsRows=true] - postgres only: SELECT * FROM fn()
|
|
166
|
+
* when true, CALL proc() when false. The
|
|
167
|
+
* other two dialects have one form each.
|
|
168
|
+
* @returns {{ sql: string, params: Array, named: boolean }}
|
|
169
|
+
* `named` reports whether the dialect actually honoured the names —
|
|
170
|
+
* false on MySQL, so a caller can warn rather than let somebody
|
|
171
|
+
* believe their names did something.
|
|
172
|
+
*/
|
|
173
|
+
function buildProcedureCall(opts) {
|
|
174
|
+
var dbType = opts.dbType;
|
|
175
|
+
var name = opts.name;
|
|
176
|
+
if (!name) throw new Error('a procedure call needs a procedure name');
|
|
177
|
+
|
|
178
|
+
var entries = normalizeParams((opts.params || []).concat(windowParams(opts.window)));
|
|
179
|
+
var returnsRows = opts.returnsRows !== false;
|
|
180
|
+
var ph = placeholders(dbType);
|
|
181
|
+
var supportsNames = dbType === 'mssql' || dbType === 'postgres';
|
|
182
|
+
|
|
183
|
+
var marks = entries.map(function(p, i) {
|
|
184
|
+
var mark = ph(i);
|
|
185
|
+
if (!p.name || !supportsNames) return mark;
|
|
186
|
+
if (dbType === 'mssql') return '@' + p.name + ' = ' + mark;
|
|
187
|
+
return p.name + ' => ' + mark; // postgres named-argument call
|
|
188
|
+
}).join(', ');
|
|
189
|
+
|
|
190
|
+
var quoted = quoteQualifiedIdent(opts.driver, name);
|
|
191
|
+
var sql;
|
|
192
|
+
switch (dbType) {
|
|
193
|
+
case 'mssql':
|
|
194
|
+
sql = 'EXEC ' + quoted + (marks ? ' ' + marks : '');
|
|
195
|
+
break;
|
|
196
|
+
case 'mysql':
|
|
197
|
+
sql = 'CALL ' + quoted + '(' + marks + ')';
|
|
198
|
+
break;
|
|
199
|
+
case 'postgres':
|
|
200
|
+
sql = returnsRows
|
|
201
|
+
? 'SELECT * FROM ' + quoted + '(' + marks + ')'
|
|
202
|
+
: 'CALL ' + quoted + '(' + marks + ')';
|
|
203
|
+
break;
|
|
204
|
+
default:
|
|
205
|
+
throw new Error('calling a procedure is not supported for "' + dbType + '"');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
sql: sql,
|
|
210
|
+
params: entries.map(function(p) { return p.value; }),
|
|
211
|
+
named: supportsNames && entries.some(function(p) { return Boolean(p.name); })
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = {
|
|
216
|
+
buildProcedureCall: buildProcedureCall,
|
|
217
|
+
windowParams: windowParams,
|
|
218
|
+
normalizeParams: normalizeParams,
|
|
219
|
+
normalizeParamValue: normalizeParamValue,
|
|
220
|
+
quoteQualifiedIdent: quoteQualifiedIdent,
|
|
221
|
+
placeholders: placeholders
|
|
222
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// WHERE AN EMAIL ACTION GETS ITS MAILBOX FROM.
|
|
2
|
+
//
|
|
3
|
+
// One checkbox: `useCustomConnection`.
|
|
4
|
+
//
|
|
5
|
+
// OFF (default) — the mail service this install already runs. Every product
|
|
6
|
+
// here needs email for notifications anyway, so it is always
|
|
7
|
+
// configured; a step should not have to ask again.
|
|
8
|
+
// ON — a connection supplied on the step, for a workflow that
|
|
9
|
+
// talks to a mailbox the platform does not own (a client's,
|
|
10
|
+
// a shared alias).
|
|
11
|
+
//
|
|
12
|
+
// Off is the default deliberately. Requiring host/user/password on every step
|
|
13
|
+
// meant pasting credentials into a workflow document — visible to anyone who
|
|
14
|
+
// can open the builder, duplicated per step, and stale the day the mail server
|
|
15
|
+
// moves.
|
|
16
|
+
|
|
17
|
+
// ── inbound (IMAP) ──────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function num(v, fallback) {
|
|
20
|
+
if (v === undefined || v === null || v === '') return fallback;
|
|
21
|
+
var n = Number(v);
|
|
22
|
+
return isFinite(n) ? n : fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function bool(v, fallback) {
|
|
26
|
+
if (v === undefined || v === null || v === '') return fallback;
|
|
27
|
+
var s = String(v).trim().toLowerCase();
|
|
28
|
+
if (['1', 'true', 'yes', 'on'].indexOf(s) !== -1) return true;
|
|
29
|
+
if (['0', 'false', 'no', 'off'].indexOf(s) !== -1) return false;
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The install's IMAP mailbox, from env.
|
|
35
|
+
*
|
|
36
|
+
* IMAP_* wins, then SMTP_* — a mailbox is usually one account, and an install
|
|
37
|
+
* that set SMTP credentials for sending has almost always given the same ones
|
|
38
|
+
* for reading. Falling back beats making people set the same secret twice
|
|
39
|
+
* under a second name.
|
|
40
|
+
*
|
|
41
|
+
* NOTE there is no "system IMAP service" the way there is a system MAIL
|
|
42
|
+
* SENDER: @xeplr/email is outbound only. So for inbound, "system" means this
|
|
43
|
+
* env block and nothing else.
|
|
44
|
+
*/
|
|
45
|
+
function imapFromEnv() {
|
|
46
|
+
var env = process.env;
|
|
47
|
+
var host = env.IMAP_HOST || env.SMTP_HOST;
|
|
48
|
+
var user = env.IMAP_USER || env.SMTP_USER;
|
|
49
|
+
var password = env.IMAP_PASS || env.SMTP_PASS;
|
|
50
|
+
|
|
51
|
+
if (!host || !user || !password) {
|
|
52
|
+
var missing = [];
|
|
53
|
+
if (!host) missing.push('IMAP_HOST (or SMTP_HOST)');
|
|
54
|
+
if (!user) missing.push('IMAP_USER (or SMTP_USER)');
|
|
55
|
+
if (!password) missing.push('IMAP_PASS (or SMTP_PASS)');
|
|
56
|
+
var err = new Error(
|
|
57
|
+
'This install has no mailbox configured — missing ' + missing.join(', ') +
|
|
58
|
+
'. Set those, or tick "Provide my own connection" on the step and supply one.'
|
|
59
|
+
);
|
|
60
|
+
err.code = 'EMAIL_SYSTEM_NOT_CONFIGURED';
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
var tls = {};
|
|
65
|
+
if (env.IMAP_TLS_REJECT_UNAUTHORIZED !== undefined) tls.rejectUnauthorized = bool(env.IMAP_TLS_REJECT_UNAUTHORIZED, undefined);
|
|
66
|
+
if (env.IMAP_TLS_SERVERNAME) tls.servername = env.IMAP_TLS_SERVERNAME;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
host: host,
|
|
70
|
+
port: num(env.IMAP_PORT, 993),
|
|
71
|
+
secure: bool(env.IMAP_SECURE, true),
|
|
72
|
+
user: user,
|
|
73
|
+
password: password,
|
|
74
|
+
connectionTimeout: num(env.IMAP_CONNECTION_TIMEOUT, undefined),
|
|
75
|
+
socketTimeout: num(env.IMAP_SOCKET_TIMEOUT, undefined),
|
|
76
|
+
tls: Object.keys(tls).length ? tls : undefined
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the inbound connection an action should use.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} input the action's own input (connectionSource, connection)
|
|
84
|
+
*/
|
|
85
|
+
function resolveInbound(input) {
|
|
86
|
+
input = input || {};
|
|
87
|
+
if (!input.useCustomConnection) return imapFromEnv();
|
|
88
|
+
if (!input.connection || !input.connection.host) {
|
|
89
|
+
throw new Error('"Provide my own connection" is on but no connection was supplied — set { host, user, password }, or turn it off to use this install\'s mailbox.');
|
|
90
|
+
}
|
|
91
|
+
return input.connection;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── outbound (send) ─────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether this action should hand off to the install's own mail sender.
|
|
98
|
+
*
|
|
99
|
+
* Outbound "system" is NOT an env block the way inbound is — it is
|
|
100
|
+
* @xeplr/utils' sendEmail, which is what @xeplr/email configures at startup
|
|
101
|
+
* and which may be pointed at SES, Azure or Brevo rather than SMTP. Rebuilding
|
|
102
|
+
* an SMTP connection out of env here would quietly ignore that and send
|
|
103
|
+
* through the wrong provider, so the system path delegates instead.
|
|
104
|
+
*/
|
|
105
|
+
function usesSystemSender(input) {
|
|
106
|
+
return !(input && input.useCustomConnection);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveOutbound(input) {
|
|
110
|
+
input = input || {};
|
|
111
|
+
if (!input.connection || !input.connection.host) {
|
|
112
|
+
throw new Error('"Provide my own connection" is on but no connection was supplied — set { host, user, password, from }, or turn it off to use this install\'s email service.');
|
|
113
|
+
}
|
|
114
|
+
return input.connection;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
imapFromEnv: imapFromEnv,
|
|
119
|
+
resolveInbound: resolveInbound,
|
|
120
|
+
resolveOutbound: resolveOutbound,
|
|
121
|
+
usesSystemSender: usesSystemSender
|
|
122
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Shared on-disk helpers for the email actions that write files
|
|
2
|
+
// (email-download-email, email-download-attachments).
|
|
3
|
+
//
|
|
4
|
+
// Output goes to XEPLR_ACTIONS_TMP_DIR — the SAME directory db-fetch spools
|
|
5
|
+
// NDJSON into — so every artifact an action leaves behind is swept by one
|
|
6
|
+
// mechanism rather than one per action family. An explicit `outDir` input
|
|
7
|
+
// still wins for the case where a later step (or another service) needs the
|
|
8
|
+
// file somewhere specific.
|
|
9
|
+
|
|
10
|
+
var crypto = require('crypto');
|
|
11
|
+
var path = require('path');
|
|
12
|
+
var fs = require('fs');
|
|
13
|
+
var fsp = require('fs/promises');
|
|
14
|
+
var os = require('os');
|
|
15
|
+
|
|
16
|
+
// Short random prefix. Attachment names collide constantly in mail —
|
|
17
|
+
// "invoice.pdf" from fifty senders — and a download that silently overwrites
|
|
18
|
+
// the previous one is the kind of bug you find a quarter later in the numbers.
|
|
19
|
+
function shortId() {
|
|
20
|
+
return crypto.randomBytes(5).toString('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Anything outside word/dot/dash becomes '_'. A filename arriving from a
|
|
24
|
+
// mailbox is attacker-controlled: without this, a name like
|
|
25
|
+
// "../../etc/cron.d/x" would escape the output directory entirely.
|
|
26
|
+
// Capped so a pathological name can't blow the filesystem's limit.
|
|
27
|
+
function sanitizeFilename(name) {
|
|
28
|
+
var out = String(name || 'attachment').replace(/[^\w.\-]+/g, '_').slice(0, 180);
|
|
29
|
+
// Stripping separators is not enough on its own: a name of "." or ".." has
|
|
30
|
+
// none left and is STILL a traversal, because path.join(dir, '..') resolves
|
|
31
|
+
// above dir. Any all-dots name gets replaced outright.
|
|
32
|
+
if (/^\.+$/.test(out)) return 'attachment';
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Explicit outDir wins; otherwise the same temp root the rest of the actions
|
|
37
|
+
// use. Created recursively — a first run has no directory yet.
|
|
38
|
+
async function resolveOutDir(outDir) {
|
|
39
|
+
var dir = outDir || process.env.XEPLR_ACTIONS_TMP_DIR || path.join(os.tmpdir(), 'xeplr-actions');
|
|
40
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Filter predicate for attachment selection. Both filters optional; with
|
|
45
|
+
// neither, everything matches.
|
|
46
|
+
// extension — 'pdf' or '.pdf' (case-insensitive suffix)
|
|
47
|
+
// filenameContains — case-insensitive substring
|
|
48
|
+
function attachmentMatches(name, extension, filenameContains) {
|
|
49
|
+
var n = String(name || '').toLowerCase();
|
|
50
|
+
if (extension) {
|
|
51
|
+
var ext = String(extension).toLowerCase().replace(/^\./, '');
|
|
52
|
+
if (n.slice(-(ext.length + 1)) !== '.' + ext) return false;
|
|
53
|
+
}
|
|
54
|
+
if (filenameContains && n.indexOf(String(filenameContains).toLowerCase()) === -1) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// On-disk name for a whole-message .eml. Prefers the subject — that is what a
|
|
61
|
+
// human recognises in a folder listing — and falls back to the uid when the
|
|
62
|
+
// message has none. Always prefixed, because "Re: Invoice" is not unique.
|
|
63
|
+
function emlFilename(subject, uid) {
|
|
64
|
+
var base = sanitizeFilename(subject || ('message_' + uid)).replace(/\.eml$/i, '');
|
|
65
|
+
return shortId() + '_' + base + '.eml';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
shortId: shortId,
|
|
70
|
+
sanitizeFilename: sanitizeFilename,
|
|
71
|
+
resolveOutDir: resolveOutDir,
|
|
72
|
+
attachmentMatches: attachmentMatches,
|
|
73
|
+
emlFilename: emlFilename,
|
|
74
|
+
fs: fs,
|
|
75
|
+
path: path
|
|
76
|
+
};
|