@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.
@@ -31,7 +31,7 @@ var TYPE_MAP = {
31
31
  string: 'NVARCHAR(MAX)',
32
32
  number: 'DECIMAL(38,10)',
33
33
  boolean: 'BIT',
34
- date: 'DATETIME2',
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
- date: 'datetime', time: 'datetime', datetimeoffset: 'datetime'
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
- return { rows: rows, rowCount: rowCount };
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
 
@@ -28,7 +28,7 @@ var TYPE_MAP = {
28
28
  string: 'LONGTEXT',
29
29
  number: 'DECIMAL(38,10)',
30
30
  boolean: 'TINYINT(1)',
31
- date: 'DATETIME(6)',
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
- date: 'datetime', datetime: 'datetime', timestamp: 'datetime',
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 }; // SELECT
140
+ return { rows: out, rowCount: out.length, columns: columns }; // SELECT
85
141
  }
86
- return { rows: [], rowCount: (out && out.affectedRows) || 0 }; // DML
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: 'TIMESTAMPTZ',
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
- return pool.query(sql, params || []);
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
+ };