@xeplr/actions 1.1.1 → 1.1.5

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,395 @@
1
+ // IMAP provider — the inbound half of the email actions (read, move, delete,
2
+ // download_email, download_attachments).
3
+ //
4
+ // PEER DEPS ARE LAZY. `imapflow` and `mailparser` are optional peers and are
5
+ // required INSIDE the call that needs them, never at module load, so a
6
+ // deployment that only sends mail never has to install them and importing the
7
+ // builtins index doesn't throw. checkRequires() below is what turns a missing
8
+ // one into a clear message instead of MODULE_NOT_FOUND.
9
+ //
10
+ // CONNECTION LIFECYCLE mirrors the db drivers: connect → use → close, one
11
+ // connection per action call, released in a `finally`. An action that touches
12
+ // several messages (read pulling 50, download pulling all attachments of one)
13
+ // does all of it on that single connection rather than reconnecting per
14
+ // message.
15
+ //
16
+ // A NOTE ON `uid`. Every operation here addresses a message by IMAP UID, which
17
+ // is scoped to (folder, UIDVALIDITY) — it is NOT a global message id, and a
18
+ // message's UID CHANGES when it moves folders. Hence `folder` on every action:
19
+ // the uid alone is not an address. Sequence a workflow so any move is the last
20
+ // step on a message, or re-read after moving.
21
+
22
+ var files = require('./files');
23
+
24
+ // ─── peer deps ──────────────────────────────────────────────────────────
25
+
26
+ function loadImapFlow(action) {
27
+ try {
28
+ return require('imapflow').ImapFlow;
29
+ } catch (_) {
30
+ throw new Error(action + ": 'imapflow' is not installed. Run `npm install imapflow` to enable IMAP support.");
31
+ }
32
+ }
33
+
34
+ function loadMailParser(action) {
35
+ try {
36
+ return require('mailparser').simpleParser;
37
+ } catch (_) {
38
+ throw new Error(action + ": 'mailparser' is not installed. Run `npm install mailparser` to enable message parsing.");
39
+ }
40
+ }
41
+
42
+ // Called by each action before it connects, so a missing peer dep fails with
43
+ // the install line rather than at some arbitrary point mid-run.
44
+ function checkRequires(action, opts) {
45
+ loadImapFlow(action);
46
+ if (opts && opts.needsParser) loadMailParser(action);
47
+ }
48
+
49
+ // ─── config ─────────────────────────────────────────────────────────────
50
+
51
+ // Drop undefined keys so we never overwrite an imapflow default with
52
+ // `undefined` by spreading.
53
+ function compact(obj) {
54
+ var out = {};
55
+ Object.keys(obj || {}).forEach(function(k) {
56
+ if (obj[k] !== undefined) out[k] = obj[k];
57
+ });
58
+ return out;
59
+ }
60
+
61
+ // Flat `connection` input → imapflow client config. Flat on purpose: it is the
62
+ // same shape db-fetch takes ({ host, port, user, password, ... }), so a
63
+ // connection reads the same across every action in this package.
64
+ //
65
+ // Host/user/password are hard requirements — IMAP cannot be attempted blind,
66
+ // and a half-specified transport fails as an opaque timeout rather than a
67
+ // useful error.
68
+ function buildClientConfig(action, connection) {
69
+ var c = connection || {};
70
+ if (!c.host || !c.user || !c.password) {
71
+ throw new Error(action + ': connection requires { host, user, password } (port/secure default to 993/true).');
72
+ }
73
+ return compact({
74
+ host: c.host,
75
+ // 993 is implicit TLS, 143 is STARTTLS. Defaulting to the secure pair
76
+ // rather than the plaintext one — an unencrypted mailbox login should be
77
+ // something you opt into, not something you get by omission.
78
+ port: c.port === undefined ? 993 : c.port,
79
+ secure: c.secure === undefined ? true : c.secure,
80
+ auth: { user: c.user, pass: c.password },
81
+ connectionTimeout: c.connectionTimeout,
82
+ greetTimeout: c.greetTimeout,
83
+ socketTimeout: c.socketTimeout,
84
+ tls: (c.tls && Object.keys(c.tls).length > 0) ? c.tls : undefined,
85
+ // imapflow's own logger writes a line per protocol command at info level.
86
+ // The actions return structured results; the protocol chatter belongs off
87
+ // by default and behind an explicit flag.
88
+ logger: c.debug ? undefined : false
89
+ });
90
+ }
91
+
92
+ // ─── connection helper ──────────────────────────────────────────────────
93
+
94
+ // connect → open mailbox → run → release lock → logout. Every inbound op goes
95
+ // through this so the release/logout pair can never be forgotten in one of
96
+ // them. `logout()` is best-effort in its own catch: a failure there must not
97
+ // mask the real error from the work, and must not fail an op that succeeded.
98
+ async function withMailbox(action, connection, folder, fn) {
99
+ // Config FIRST, deps second. Validating the connection needs no peer dep, so
100
+ // a misconfigured connection reports the missing field even on a host that
101
+ // hasn't installed imapflow — otherwise the dep error masks the real one.
102
+ var config = buildClientConfig(action, connection);
103
+ var ImapFlow = loadImapFlow(action);
104
+ var client = new ImapFlow(config);
105
+
106
+ await client.connect();
107
+ try {
108
+ var lock = await client.getMailboxLock(folder || 'INBOX');
109
+ try {
110
+ return await fn(client);
111
+ } finally {
112
+ lock.release();
113
+ }
114
+ } finally {
115
+ try { await client.logout(); } catch (_) { /* connection already gone */ }
116
+ }
117
+ }
118
+
119
+ // ─── helpers ────────────────────────────────────────────────────────────
120
+
121
+ // imapflow takes an object of IMAP SEARCH keys. Note that IMAP's FROM and
122
+ // SUBJECT are SUBSTRING matches by specification — there is no exact-match
123
+ // key — so `from_equals` is sent as a substring narrowing here and then
124
+ // enforced exactly on the client side in read(). Sending it still pays: it
125
+ // keeps the server from returning the whole mailbox.
126
+ function buildSearch(input) {
127
+ var criteria = {};
128
+ if (input.unread_only) criteria.seen = false;
129
+ if (input.since) criteria.since = new Date(input.since);
130
+ if (input.from_contains || input.from_equals) criteria.from = input.from_contains || input.from_equals;
131
+ if (input.subject_contains) criteria.subject = input.subject_contains;
132
+ // An empty object matches nothing on some servers; ALL is the explicit
133
+ // "everything in this mailbox".
134
+ if (Object.keys(criteria).length === 0) criteria.all = true;
135
+ return criteria;
136
+ }
137
+
138
+ // Post-filters that IMAP cannot express. from_equals is the real one — the
139
+ // server can only narrow to a substring, so "a@b.com" would otherwise also
140
+ // match "not-a@b.com".
141
+ function passesClientFilters(input, from_addr, subject) {
142
+ var s = String(subject || '').toLowerCase();
143
+ var f = String(from_addr || '').toLowerCase();
144
+ if (input.subject_contains && s.indexOf(String(input.subject_contains).toLowerCase()) === -1) return false;
145
+ if (input.from_equals && f !== String(input.from_equals).toLowerCase()) return false;
146
+ if (input.from_contains && f.indexOf(String(input.from_contains).toLowerCase()) === -1) return false;
147
+ return true;
148
+ }
149
+
150
+ // Walk a BODYSTRUCTURE for a part that is dispositioned as an attachment.
151
+ // Used on the metadata-only path, where the message source is never
152
+ // downloaded and mailparser therefore cannot tell us this.
153
+ function structureHasAttachment(node) {
154
+ if (!node) return false;
155
+ if (String(node.disposition || '').toLowerCase() === 'attachment') return true;
156
+ var kids = node.childNodes || node.children || [];
157
+ for (var i = 0; i < kids.length; i++) {
158
+ if (structureHasAttachment(kids[i])) return true;
159
+ }
160
+ return false;
161
+ }
162
+
163
+ function addressList(list) {
164
+ return (list || []).map(function(a) { return a.address; }).filter(Boolean);
165
+ }
166
+
167
+ // ─── operations ─────────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * Read messages from a mailbox, newest first.
171
+ *
172
+ * TWO PATHS, chosen by `body`:
173
+ * 'none' → ENVELOPE + FLAGS + BODYSTRUCTURE fetch only. Nothing is
174
+ * downloaded or parsed, which is what makes a monitor that just
175
+ * wants ids/subjects cheap enough to run on a schedule.
176
+ * else → full source download + mailparser, because bodies (and a
177
+ * reliable attachment flag) genuinely require the message.
178
+ *
179
+ * The split matters: downloading every matched message purely to read its
180
+ * subject means fetching megabytes of inline base64 to keep a 40-byte field.
181
+ */
182
+ async function read(connection, input) {
183
+ input = input || {};
184
+ var folder = input.folder || 'INBOX';
185
+ var body = input.body || 'full';
186
+ var wantHtml = body === 'full';
187
+ var wantText = body === 'full' || body === 'text';
188
+ var wantBodies = body !== 'none';
189
+
190
+ // Validate the connection before loading the parser, for the same reason
191
+ // withMailbox does: a bad connection should say which field is missing
192
+ // rather than report a peer dep that is only needed once it works.
193
+ buildClientConfig('email-read', connection);
194
+ var simpleParser = wantBodies ? loadMailParser('email-read') : null;
195
+
196
+ return withMailbox('email-read', connection, folder, async function(client) {
197
+ var uids = (await client.search(buildSearch(input), { uid: true })) || [];
198
+
199
+ // Cap defensively — `limit` reaches this from a workflow input, and an
200
+ // unbounded mailbox read is how a step OOMs the runner.
201
+ var cap = Math.min(Math.max(Number(input.limit) || 50, 1), 1000);
202
+ var slice = uids.slice(-cap).reverse(); // newest first
203
+ if (slice.length === 0) return [];
204
+
205
+ var out = [];
206
+
207
+ if (!wantBodies) {
208
+ var fetchOpts = { uid: true, envelope: true, flags: true, bodyStructure: true };
209
+ for await (var msg of client.fetch(slice, fetchOpts, { uid: true })) {
210
+ var env = msg.envelope || {};
211
+ var fromAddr = (env.from && env.from[0] && env.from[0].address) || '';
212
+ if (!passesClientFilters(input, fromAddr, env.subject)) continue;
213
+ out.push({
214
+ id: String(msg.uid),
215
+ uid: msg.uid,
216
+ subject: env.subject || '',
217
+ from: fromAddr,
218
+ from_name: (env.from && env.from[0] && env.from[0].name) || null,
219
+ to: addressList(env.to),
220
+ cc: addressList(env.cc),
221
+ received_at: env.date ? new Date(env.date).toISOString() : null,
222
+ has_attachments: structureHasAttachment(msg.bodyStructure),
223
+ body_html: null,
224
+ body_text: null,
225
+ // The real flag, not a hardcoded false — `\Seen` is what "read"
226
+ // means, and a monitor that re-processes already-handled mail
227
+ // because this lied is a costly kind of wrong.
228
+ is_read: !!(msg.flags && msg.flags.has && msg.flags.has('\\Seen')),
229
+ message_id: env.messageId || null,
230
+ folder: folder
231
+ });
232
+ }
233
+ return out;
234
+ }
235
+
236
+ for (var i = 0; i < slice.length; i++) {
237
+ var uid = slice[i];
238
+ var dl = await client.download(uid, undefined, { uid: true });
239
+ var parsed = await simpleParser(dl.content);
240
+ var addr = (parsed.from && parsed.from.value && parsed.from.value[0] && parsed.from.value[0].address) || '';
241
+ if (!passesClientFilters(input, addr, parsed.subject)) continue;
242
+ out.push({
243
+ id: String(uid),
244
+ uid: uid,
245
+ subject: parsed.subject || '',
246
+ from: addr,
247
+ from_name: (parsed.from && parsed.from.value && parsed.from.value[0] && parsed.from.value[0].name) || null,
248
+ to: addressList(parsed.to && parsed.to.value),
249
+ cc: addressList(parsed.cc && parsed.cc.value),
250
+ received_at: parsed.date ? parsed.date.toISOString() : null,
251
+ has_attachments: Array.isArray(parsed.attachments) && parsed.attachments.length > 0,
252
+ body_html: wantHtml ? (parsed.html || null) : null,
253
+ body_text: wantText ? (parsed.text || null) : null,
254
+ is_read: null, // not fetched on this path; use body:'none' for flags
255
+ message_id: parsed.messageId || null,
256
+ folder: folder
257
+ });
258
+ }
259
+ return out;
260
+ });
261
+ }
262
+
263
+ /** Move one message to another folder. Returns the shape the action wraps. */
264
+ async function move(connection, input) {
265
+ input = input || {};
266
+ if (!input.messageId) throw new Error('email-move: messageId is required.');
267
+ if (!input.toFolder) throw new Error('email-move: toFolder is required.');
268
+
269
+ return withMailbox('email-move', connection, input.folder || 'INBOX', async function(client) {
270
+ await client.messageMove(String(input.messageId), input.toFolder, { uid: true });
271
+ return {
272
+ moved: true,
273
+ messageId: String(input.messageId),
274
+ fromFolder: input.folder || 'INBOX',
275
+ toFolder: input.toFolder
276
+ };
277
+ });
278
+ }
279
+
280
+ /**
281
+ * Delete one message.
282
+ *
283
+ * PERMANENT. imapflow's messageDelete flags \Deleted and EXPUNGES, so this is
284
+ * not a move to Trash and there is nothing to undo. Moving to the server's
285
+ * trash folder is `email-move`, and is what you usually want.
286
+ */
287
+ async function remove(connection, input) {
288
+ input = input || {};
289
+ if (!input.messageId) throw new Error('email-delete: messageId is required.');
290
+
291
+ return withMailbox('email-delete', connection, input.folder || 'INBOX', async function(client) {
292
+ await client.messageDelete(String(input.messageId), { uid: true });
293
+ return { deleted: true, messageId: String(input.messageId), folder: input.folder || 'INBOX' };
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Save the whole message as an RFC-822 .eml.
299
+ *
300
+ * Streamed straight to disk, never parsed and never buffered whole: IMAP's raw
301
+ * source IS the .eml format, so there is nothing to build, and a message
302
+ * carrying a 40 MB attachment must not become a 40 MB string first.
303
+ *
304
+ * The subject comes from a separate ENVELOPE fetch — cheap metadata, not a
305
+ * body download — purely so the file has a name a human recognises.
306
+ */
307
+ async function downloadEmail(connection, input) {
308
+ input = input || {};
309
+ if (!input.messageId) throw new Error('email-download-email: messageId is required.');
310
+ var pipeline = require('stream/promises').pipeline;
311
+ var uid = String(input.messageId);
312
+
313
+ return withMailbox('email-download-email', connection, input.folder || 'INBOX', async function(client) {
314
+ var subject = null;
315
+ try {
316
+ var meta = await client.fetchOne(uid, { envelope: true }, { uid: true });
317
+ subject = (meta && meta.envelope && meta.envelope.subject) || null;
318
+ } catch (_) { /* filename falls back to the uid — never fatal */ }
319
+
320
+ var dl = await client.download(uid, undefined, { uid: true });
321
+
322
+ var dir = await files.resolveOutDir(input.outDir);
323
+ var filename = files.emlFilename(subject, uid);
324
+ var filepath = files.path.join(dir, filename);
325
+
326
+ await pipeline(dl.content, files.fs.createWriteStream(filepath));
327
+
328
+ var size = files.fs.statSync(filepath).size;
329
+ return {
330
+ count: 1,
331
+ // Same shape as download-attachments, so a downstream step binds to
332
+ // `path` / `saved[]` identically whichever produced the file.
333
+ saved: [{ filename: filename, path: filepath, size: size, contentType: 'message/rfc822' }],
334
+ path: filepath,
335
+ subject: subject,
336
+ messageId: uid
337
+ };
338
+ });
339
+ }
340
+
341
+ /**
342
+ * Save a message's attachments to disk, optionally filtered.
343
+ *
344
+ * Unlike downloadEmail this MUST parse — attachments are MIME parts, and
345
+ * extracting one means decoding the message. Writes are streamed per part.
346
+ */
347
+ async function downloadAttachments(connection, input) {
348
+ input = input || {};
349
+ if (!input.messageId) throw new Error('email-download-attachments: messageId is required.');
350
+ var simpleParser = loadMailParser('email-download-attachments');
351
+ var uid = String(input.messageId);
352
+
353
+ return withMailbox('email-download-attachments', connection, input.folder || 'INBOX', async function(client) {
354
+ var dl = await client.download(uid, undefined, { uid: true });
355
+ var parsed = await simpleParser(dl.content);
356
+ var all = parsed.attachments || [];
357
+
358
+ var dir = await files.resolveOutDir(input.outDir);
359
+ var saved = [];
360
+ var skipped = 0;
361
+
362
+ for (var i = 0; i < all.length; i++) {
363
+ var a = all[i];
364
+ if (!files.attachmentMatches(a.filename, input.extension, input.filenameContains)) { skipped++; continue; }
365
+ var filename = files.shortId() + '_' + files.sanitizeFilename(a.filename);
366
+ var filepath = files.path.join(dir, filename);
367
+ files.fs.writeFileSync(filepath, a.content);
368
+ saved.push({
369
+ filename: a.filename || filename,
370
+ path: filepath,
371
+ size: a.size || (a.content && a.content.length) || null,
372
+ contentType: a.contentType || null
373
+ });
374
+ }
375
+
376
+ return {
377
+ count: saved.length,
378
+ saved: saved,
379
+ path: saved.length ? saved[0].path : null,
380
+ // Reported rather than silent: "0 attachments" and "6 attachments, none
381
+ // matched your filter" are different problems with the same count.
382
+ skipped: skipped,
383
+ messageId: uid
384
+ };
385
+ });
386
+ }
387
+
388
+ module.exports = {
389
+ checkRequires: checkRequires,
390
+ read: read,
391
+ move: move,
392
+ delete: remove,
393
+ downloadEmail: downloadEmail,
394
+ downloadAttachments: downloadAttachments
395
+ };
@@ -0,0 +1,45 @@
1
+ // Email provider registry — the same shape as lib/drivers/db: an action names
2
+ // a provider, this resolves it, and the action never learns which one it got.
3
+ //
4
+ // TODAY: 'imap' handles all five inbound operations and 'smtp' handles sending.
5
+ // They are separate providers rather than one because they are separate
6
+ // protocols on separate ports with separate credentials — a mailbox that
7
+ // receives over IMAP frequently sends through a different relay entirely.
8
+ //
9
+ // A future 'graph' provider (Microsoft 365, where one OAuth app covers both
10
+ // directions) plugs in here by implementing the same method names; no action
11
+ // changes.
12
+
13
+ var imap = require('./imap');
14
+ var smtp = require('./smtp');
15
+
16
+ var INBOUND = {
17
+ imap: imap
18
+ };
19
+
20
+ var OUTBOUND = {
21
+ smtp: smtp
22
+ };
23
+
24
+ function getInboundProvider(name) {
25
+ var key = String(name || 'imap').toLowerCase();
26
+ var provider = INBOUND[key];
27
+ if (!provider) {
28
+ throw new Error('email: unknown inbound provider "' + key + '" (supported: ' + Object.keys(INBOUND).join(', ') + ')');
29
+ }
30
+ return provider;
31
+ }
32
+
33
+ function getOutboundProvider(name) {
34
+ var key = String(name || 'smtp').toLowerCase();
35
+ var provider = OUTBOUND[key];
36
+ if (!provider) {
37
+ throw new Error('email: unknown outbound provider "' + key + '" (supported: ' + Object.keys(OUTBOUND).join(', ') + ')');
38
+ }
39
+ return provider;
40
+ }
41
+
42
+ module.exports = {
43
+ getInboundProvider: getInboundProvider,
44
+ getOutboundProvider: getOutboundProvider
45
+ };
@@ -0,0 +1,214 @@
1
+ // SMTP provider — the outbound half (email-send).
2
+ //
3
+ // `nodemailer` is an optional peer, lazy-required like the IMAP deps, so a
4
+ // deployment that only READS mail never has to install it.
5
+ //
6
+ // TRANSPORTS ARE NOT CACHED between calls. An action receives its connection
7
+ // as input and may legitimately be pointed at a different mailbox on the next
8
+ // run; a module-level cached transporter would silently keep sending through
9
+ // whichever one happened to be first. Reconnecting per send is the cost of
10
+ // that correctness, and SMTP connection setup is not the expensive part of
11
+ // sending mail.
12
+
13
+ var files = require('./files');
14
+
15
+ function loadNodemailer(action) {
16
+ try {
17
+ return require('nodemailer');
18
+ } catch (_) {
19
+ throw new Error(action + ": 'nodemailer' is not installed. Run `npm install nodemailer` to enable sending.");
20
+ }
21
+ }
22
+
23
+ // nodemailer is only needed for the CUSTOM path — the system sender brings its
24
+ // own transport. Checking it unconditionally would demand an install that
25
+ // sends through SES also install an SMTP library it never uses.
26
+ function checkRequires(action, opts) {
27
+ if (opts && opts.useSystem) return;
28
+ loadNodemailer(action);
29
+ }
30
+
31
+ function compact(obj) {
32
+ var out = {};
33
+ Object.keys(obj || {}).forEach(function(k) {
34
+ if (obj[k] !== undefined) out[k] = obj[k];
35
+ });
36
+ return out;
37
+ }
38
+
39
+ // Flat `connection` input → nodemailer transport config, matching the shape
40
+ // the IMAP side takes so one connection object reads the same either way.
41
+ function buildTransportConfig(action, connection) {
42
+ var c = connection || {};
43
+ if (!c.host) throw new Error(action + ': connection requires { host } (and usually { user, password, from }).');
44
+
45
+ var config = compact({
46
+ host: c.host,
47
+ port: c.port,
48
+ // 465 is implicit SSL; 587/25 are STARTTLS. Derived from the port when
49
+ // not stated, because the wrong pairing is the single most common way an
50
+ // SMTP config fails — and it fails as a hang, not a message.
51
+ secure: c.secure === undefined ? (Number(c.port) === 465) : c.secure,
52
+ requireTLS: c.requireTLS,
53
+ ignoreTLS: c.ignoreTLS,
54
+ name: c.name, // EHLO/HELO hostname; some servers are picky
55
+ connectionTimeout: c.connectionTimeout,
56
+ greetingTimeout: c.greetingTimeout,
57
+ socketTimeout: c.socketTimeout,
58
+ pool: c.pool,
59
+ maxConnections: c.maxConnections,
60
+ maxMessages: c.maxMessages,
61
+ authMethod: c.authMethod,
62
+ logger: c.debug ? undefined : false,
63
+ debug: c.debug
64
+ });
65
+
66
+ if (c.user || c.password) {
67
+ config.auth = compact({ user: c.user, pass: c.password });
68
+ }
69
+ if (c.tls && Object.keys(c.tls).length > 0) {
70
+ config.tls = c.tls;
71
+ }
72
+
73
+ // `options` — the escape hatch, mirroring SMTP_OPTIONS on the env side (see
74
+ // @xeplr/utils smtpFromEnv). The named fields above cover the common ground;
75
+ // this carries whatever one particular server needs that nothing here has a
76
+ // name for. Merged LAST so it can override them, with `tls` merged a level
77
+ // deeper rather than replaced.
78
+ if (c.options && typeof c.options === 'object' && !Array.isArray(c.options)) {
79
+ var extraTls = c.options.tls;
80
+ Object.keys(c.options).forEach(function(k) {
81
+ if (k !== 'tls') config[k] = c.options[k];
82
+ });
83
+ if (extraTls) config.tls = Object.assign({}, config.tls, extraTls);
84
+ }
85
+
86
+ return config;
87
+ }
88
+
89
+ /**
90
+ * Send one message.
91
+ *
92
+ * Attachments accept nodemailer's own descriptors — `{ filename, path }`,
93
+ * `{ filename, content }`, `{ filename, href }` — which means the output of
94
+ * email-download-attachments (`saved[]`, each carrying `filename` + `path`)
95
+ * can be handed straight back in to forward what was just downloaded.
96
+ *
97
+ * `envelopeFrom` sets the SMTP-level MAIL FROM / Return-Path separately from
98
+ * the header From, which is what bounce handling keys on when the two differ.
99
+ */
100
+ /**
101
+ * Send through the INSTALL'S OWN configured sender (@xeplr/utils), which is
102
+ * what @xeplr/email sets up at startup and may be SMTP, SES, Azure or Brevo.
103
+ *
104
+ * Deliberately delegates rather than rebuilding an SMTP transport from env: an
105
+ * install configured for SES would otherwise be silently sent through SMTP
106
+ * instead, which is the kind of bug that only shows up as "some mail never
107
+ * arrived".
108
+ *
109
+ * That sender takes (to, subject, html, cc, attachments) and nothing else, so
110
+ * the fields it cannot carry are REJECTED rather than dropped. Silently losing
111
+ * a bcc is worse than refusing to send: one is a visible error at build time,
112
+ * the other is a compliance question a year later.
113
+ */
114
+ async function sendViaSystem(input) {
115
+ var utils;
116
+ try {
117
+ utils = require('@xeplr/utils/lib/email');
118
+ } catch (_) {
119
+ throw new Error("email-send: this install's email service ('@xeplr/utils') is not installed. Install it, or tick \"Provide my own connection\" and supply an SMTP connection.");
120
+ }
121
+
122
+ var unsupported = ['text', 'bcc', 'from', 'replyTo'].filter(function(k) {
123
+ return input[k] !== undefined && input[k] !== '' && !(Array.isArray(input[k]) && !input[k].length);
124
+ });
125
+ if (unsupported.length) {
126
+ throw new Error(
127
+ 'email-send: the system sender does not support ' + unsupported.join(', ') +
128
+ ' — it sends to/subject/html/cc/attachments only. Remove those, or tick "Provide my own connection" and send through your own SMTP connection.'
129
+ );
130
+ }
131
+ if (!input.html) {
132
+ throw new Error('email-send: the system sender requires `html` (it has no plain-text path). Tick "Provide my own connection" to send plain text.');
133
+ }
134
+
135
+ await utils.sendEmail(input.to, input.subject, input.html, input.cc, input.attachments);
136
+ return {
137
+ sent: true,
138
+ via: 'system',
139
+ // The system sender may QUEUE rather than send inline (see its own
140
+ // sendEmail), so there is no messageId or per-recipient result to report.
141
+ // Saying so beats inventing fields that would read as confirmation.
142
+ messageId: null,
143
+ accepted: [],
144
+ rejected: [],
145
+ response: 'handed to the system email service'
146
+ };
147
+ }
148
+
149
+ /**
150
+ * @param {object|null} connection the step's own connection ('custom'), or
151
+ * null when sending through the install's configured sender ('system')
152
+ * @param {object} input
153
+ * @param {boolean} useSystem send via @xeplr/utils rather than a transport
154
+ * built here. An explicit parameter rather than a flag smuggled through
155
+ * `input`, which would be indistinguishable from a real action input.
156
+ */
157
+ async function send(connection, input, useSystem) {
158
+ input = input || {};
159
+ var c = connection || {};
160
+
161
+ var to = input.to;
162
+ if (typeof to === 'string') to = [to];
163
+ if (!to || !to.length) throw new Error('email-send: at least one recipient in `to` is required.');
164
+ if (!input.subject) throw new Error('email-send: subject is required.');
165
+ if (!input.html && !input.text) throw new Error('email-send: one of `html` or `text` is required.');
166
+
167
+ if (useSystem) {
168
+ return sendViaSystem(Object.assign({}, input, { to: to }));
169
+ }
170
+
171
+ var nodemailer = loadNodemailer('email-send');
172
+ var from = input.from || c.from;
173
+ if (!from) throw new Error('email-send: `from` is required (pass input.from or connection.from).');
174
+
175
+ var transporter = nodemailer.createTransport(buildTransportConfig('email-send', c));
176
+
177
+ try {
178
+ var mail = compact({
179
+ from: from,
180
+ to: to,
181
+ cc: input.cc,
182
+ bcc: input.bcc,
183
+ subject: input.subject,
184
+ html: input.html,
185
+ text: input.text,
186
+ replyTo: input.replyTo || c.replyTo || from,
187
+ attachments: input.attachments
188
+ });
189
+
190
+ if (c.envelopeFrom) {
191
+ mail.envelope = { from: c.envelopeFrom, to: to };
192
+ }
193
+
194
+ var info = await transporter.sendMail(mail);
195
+ return {
196
+ sent: true,
197
+ messageId: info.messageId || null,
198
+ // Servers accept per-recipient: a 200 with everything in `rejected` is a
199
+ // failed send that looks like a success unless both are surfaced.
200
+ accepted: info.accepted || [],
201
+ rejected: info.rejected || [],
202
+ response: info.response || null
203
+ };
204
+ } finally {
205
+ // Pooled transports hold sockets open and would keep the process alive.
206
+ if (typeof transporter.close === 'function') transporter.close();
207
+ }
208
+ }
209
+
210
+ module.exports = {
211
+ checkRequires: checkRequires,
212
+ send: send,
213
+ files: files
214
+ };