@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.
- package/lib/builtins/db/fetch.js +34 -5
- package/lib/builtins/db/move.js +691 -0
- package/lib/builtins/db/procedure.js +210 -0
- package/lib/builtins/db/push.js +16 -6
- package/lib/builtins/email/delete.js +42 -0
- package/lib/builtins/email/download-attachments.js +52 -0
- package/lib/builtins/email/download-email.js +45 -0
- package/lib/builtins/email/move.js +45 -0
- package/lib/builtins/email/read.js +128 -0
- package/lib/builtins/file/upload.js +6 -6
- package/lib/builtins/index.js +24 -1
- package/lib/builtins/send-email.js +114 -5
- package/lib/drivers/db/duckdb.js +646 -0
- package/lib/drivers/db/index.js +4 -1
- package/lib/drivers/db/mssql.js +32 -3
- package/lib/drivers/db/mysql.js +64 -4
- package/lib/drivers/db/postgres.js +35 -2
- package/lib/drivers/db/procedure.js +222 -0
- package/lib/drivers/email/config.js +122 -0
- package/lib/drivers/email/files.js +76 -0
- package/lib/drivers/email/imap.js +395 -0
- package/lib/drivers/email/index.js +45 -0
- package/lib/drivers/email/smtp.js +214 -0
- package/lib/runner.js +46 -1
- package/lib/streaming/spool.js +62 -2
- package/lib/uploader/attach.js +53 -7
- package/lib/uploader/index.js +84 -10
- package/lib/uploader/meta-store-knex.js +34 -5
- package/lib/uploader/meta-store-noop.js +1 -0
- package/lib/uploader/reconcile.js +12 -1
- package/package.json +22 -1
|
@@ -0,0 +1,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
|
+
};
|