sounding 0.0.0 → 0.0.2

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,391 @@
1
+ const path = require('node:path')
2
+ const url = require('node:url')
3
+
4
+ function normalizeList(value) {
5
+ if (value === undefined || value === null || value === '') {
6
+ return []
7
+ }
8
+
9
+ if (Array.isArray(value)) {
10
+ return value.flatMap((entry) => normalizeList(entry))
11
+ }
12
+
13
+ if (typeof value === 'string') {
14
+ return value
15
+ .split(',')
16
+ .map((entry) => entry.trim())
17
+ .filter(Boolean)
18
+ }
19
+
20
+ return [value]
21
+ }
22
+
23
+ function extractLinks(html) {
24
+ if (!html) {
25
+ return []
26
+ }
27
+
28
+ const matches = html.matchAll(/href=["']([^"']+)["']/gi)
29
+ return [...matches].map((match) => match[1])
30
+ }
31
+
32
+ function resolvePrimaryLink(inputs = {}, links = []) {
33
+ const templateData = inputs.templateData || {}
34
+ const preferredKeys = [
35
+ 'magicLinkUrl',
36
+ 'verificationUrl',
37
+ 'resetPasswordUrl',
38
+ 'inviteUrl',
39
+ 'checkoutUrl',
40
+ 'actionUrl',
41
+ 'url',
42
+ ]
43
+
44
+ for (const key of preferredKeys) {
45
+ if (templateData[key]) {
46
+ return templateData[key]
47
+ }
48
+ }
49
+
50
+ return links.find((link) => !/\/unsubscribe\b/i.test(link)) || links[0]
51
+ }
52
+
53
+ function createRenderViewPromise(view) {
54
+ if (!view) {
55
+ return Promise.resolve(undefined)
56
+ }
57
+
58
+ if (typeof view.intercept === 'function') {
59
+ return view.intercept((error) => error)
60
+ }
61
+
62
+ return Promise.resolve(view)
63
+ }
64
+
65
+ async function renderTemplatePreview(sails, {
66
+ template,
67
+ templateData = {},
68
+ layout = 'layout-email',
69
+ }) {
70
+ if (!template || typeof sails?.renderView !== 'function') {
71
+ return undefined
72
+ }
73
+
74
+ const emailTemplatePath = path.join('emails/', template)
75
+ let emailTemplateLayout = false
76
+
77
+ if (layout) {
78
+ emailTemplateLayout = path.relative(
79
+ path.dirname(emailTemplatePath),
80
+ path.resolve('layouts/', layout)
81
+ )
82
+ }
83
+
84
+ return createRenderViewPromise(
85
+ sails.renderView(emailTemplatePath, {
86
+ layout: emailTemplateLayout,
87
+ url,
88
+ ...templateData,
89
+ })
90
+ )
91
+ }
92
+
93
+ function resolveMailConfig(sails) {
94
+ return sails?.config?.mail || {}
95
+ }
96
+
97
+ function resolveMailerName(sails, inputs = {}) {
98
+ return inputs.mailer || process.env.MAIL_MAILER || resolveMailConfig(sails).default
99
+ }
100
+
101
+ function resolveTransportName(sails, mailer) {
102
+ return resolveMailConfig(sails).mailers?.[mailer]?.transport
103
+ }
104
+
105
+ function resolveFromAddress(sails, inputs = {}) {
106
+ return inputs.from || resolveMailConfig(sails).from?.address || process.env.MAIL_FROM_ADDRESS
107
+ }
108
+
109
+ function resolveFromName(sails, inputs = {}) {
110
+ return inputs.fromName || resolveMailConfig(sails).from?.name || process.env.MAIL_FROM_NAME
111
+ }
112
+
113
+ function resolveReplyTo(sails, inputs = {}) {
114
+ return inputs.replyTo || resolveMailConfig(sails).replyTo || process.env.MAIL_REPLY_TO
115
+ }
116
+
117
+ function toErrorShape(error) {
118
+ if (!error) {
119
+ return undefined
120
+ }
121
+
122
+ return {
123
+ name: error.name,
124
+ message: error.message,
125
+ }
126
+ }
127
+
128
+ async function buildCapturedMail(sails, inputs = {}) {
129
+ const mailer = resolveMailerName(sails, inputs)
130
+ const transport = resolveTransportName(sails, mailer)
131
+ const html = await renderTemplatePreview(sails, inputs)
132
+ const links = extractLinks(html)
133
+
134
+ return {
135
+ mailer,
136
+ transport,
137
+ template: inputs.template,
138
+ templateData: inputs.templateData || {},
139
+ to: normalizeList(inputs.to),
140
+ cc: normalizeList(inputs.cc),
141
+ bcc: normalizeList(inputs.bcc),
142
+ toName: inputs.toName,
143
+ subject: inputs.subject || '',
144
+ from: resolveFromAddress(sails, inputs),
145
+ fromName: resolveFromName(sails, inputs),
146
+ replyTo: resolveReplyTo(sails, inputs),
147
+ text: inputs.text,
148
+ html,
149
+ links,
150
+ ctaUrl: resolvePrimaryLink(inputs, links),
151
+ attachments: inputs.attachments || [],
152
+ headers: inputs.headers || {},
153
+ layout: inputs.layout === undefined ? 'layout-email' : inputs.layout,
154
+ }
155
+ }
156
+
157
+ function createMailCapture({ sails, mailbox, getConfig }) {
158
+ let originalSend = null
159
+
160
+ function resolveMailSettings() {
161
+ const soundingConfig =
162
+ typeof getConfig === 'function' ? getConfig() : sails?.config?.sounding || {}
163
+
164
+ return soundingConfig.mail || {}
165
+ }
166
+
167
+ function isEnabled() {
168
+ return resolveMailSettings().capture !== false
169
+ }
170
+
171
+ function shouldPassthrough() {
172
+ const settings = resolveMailSettings()
173
+ return settings.deliver === true || settings.mode === 'passthrough'
174
+ }
175
+
176
+ function wrapDeferred(executor, onRejected) {
177
+ let promise = Promise.resolve().then(executor)
178
+
179
+ if (typeof onRejected === 'function') {
180
+ promise = promise.catch(async (error) => {
181
+ await onRejected(error)
182
+ throw error
183
+ })
184
+ }
185
+
186
+ const deferred = {
187
+ intercept(handler) {
188
+ promise = promise.catch((error) => handler(error))
189
+ return deferred
190
+ },
191
+
192
+ then(onFulfilled, onRejected) {
193
+ return promise.then(onFulfilled, onRejected)
194
+ },
195
+
196
+ catch(onRejected) {
197
+ return promise.catch(onRejected)
198
+ },
199
+
200
+ finally(onFinally) {
201
+ return promise.finally(onFinally)
202
+ },
203
+ }
204
+
205
+ return deferred
206
+ }
207
+
208
+ function directSuccess(inputs = {}) {
209
+ return captureSuccessfulSend(inputs).then(() => ({}))
210
+ }
211
+
212
+ function directPassthrough(sendHelper, inputs = {}) {
213
+ return Promise.resolve(resolveDirectInvoker(sendHelper)(inputs))
214
+ .then(async (result) => {
215
+ await captureSuccessfulSend(inputs)
216
+ return result
217
+ })
218
+ .catch(async (error) => {
219
+ await captureFailedSend(inputs, error)
220
+ throw error
221
+ })
222
+ }
223
+
224
+ function deferredSuccess(inputs = {}) {
225
+ return wrapDeferred(async () => {
226
+ await captureSuccessfulSend(inputs)
227
+ return {}
228
+ })
229
+ }
230
+
231
+ function deferredPassthrough(sendHelper, inputs = {}) {
232
+ return wrapDeferred(
233
+ async () => {
234
+ const result = await resolveWithInvoker(sendHelper)(inputs)
235
+ await captureSuccessfulSend(inputs)
236
+ return result
237
+ },
238
+ async (error) => {
239
+ await captureFailedSend(inputs, error)
240
+ }
241
+ )
242
+ }
243
+
244
+ function resolveDirectInvoker(sendHelper) {
245
+ if (typeof sendHelper === 'function') {
246
+ return sendHelper.bind(sendHelper)
247
+ }
248
+
249
+ if (typeof sendHelper?.with === 'function') {
250
+ return sendHelper.with.bind(sendHelper)
251
+ }
252
+
253
+ throw new Error('Sounding could not invoke `sails.helpers.mail.send`.')
254
+ }
255
+
256
+ function resolveWithInvoker(sendHelper) {
257
+ if (typeof sendHelper?.with === 'function') {
258
+ return sendHelper.with.bind(sendHelper)
259
+ }
260
+
261
+ if (typeof sendHelper === 'function') {
262
+ return sendHelper.bind(sendHelper)
263
+ }
264
+
265
+ throw new Error('Sounding could not invoke `sails.helpers.mail.send.with()`.')
266
+ }
267
+
268
+ async function captureSuccessfulSend(inputs = {}) {
269
+ try {
270
+ const message = await buildCapturedMail(sails, inputs)
271
+ mailbox.capture({
272
+ ...message,
273
+ status: 'sent',
274
+ })
275
+ } catch (error) {
276
+ mailbox.capture({
277
+ mailer: resolveMailerName(sails, inputs),
278
+ transport: resolveTransportName(sails, resolveMailerName(sails, inputs)),
279
+ to: normalizeList(inputs.to),
280
+ subject: inputs.subject || '',
281
+ template: inputs.template,
282
+ status: 'sent',
283
+ captureError: toErrorShape(error),
284
+ })
285
+ }
286
+ }
287
+
288
+ async function captureFailedSend(inputs = {}, error) {
289
+ let message
290
+
291
+ try {
292
+ message = await buildCapturedMail(sails, inputs)
293
+ } catch (captureError) {
294
+ message = {
295
+ mailer: resolveMailerName(sails, inputs),
296
+ transport: resolveTransportName(sails, resolveMailerName(sails, inputs)),
297
+ to: normalizeList(inputs.to),
298
+ subject: inputs.subject || '',
299
+ template: inputs.template,
300
+ captureError: toErrorShape(captureError),
301
+ }
302
+ }
303
+
304
+ mailbox.capture({
305
+ ...message,
306
+ status: 'failed',
307
+ error: toErrorShape(error),
308
+ })
309
+ }
310
+
311
+ function wrapSendHelper(sendHelper) {
312
+ const wrapped = async (inputs = {}) => {
313
+ return shouldPassthrough()
314
+ ? directPassthrough(sendHelper, inputs)
315
+ : directSuccess(inputs)
316
+ }
317
+
318
+ const descriptors = Object.getOwnPropertyDescriptors(sendHelper)
319
+ delete descriptors.with
320
+ Object.defineProperties(wrapped, descriptors)
321
+
322
+ Object.defineProperty(wrapped, 'with', {
323
+ value(inputs = {}) {
324
+ return shouldPassthrough()
325
+ ? deferredPassthrough(sendHelper, inputs)
326
+ : deferredSuccess(inputs)
327
+ },
328
+ writable: true,
329
+ configurable: true,
330
+ })
331
+
332
+ Object.defineProperty(wrapped, '__soundingWrapped', {
333
+ value: true,
334
+ configurable: true,
335
+ })
336
+
337
+ Object.defineProperty(wrapped, '__soundingOriginal', {
338
+ value: sendHelper,
339
+ configurable: true,
340
+ })
341
+
342
+ return wrapped
343
+ }
344
+
345
+ function install() {
346
+ if (!isEnabled()) {
347
+ return false
348
+ }
349
+
350
+ const sendHelper = sails?.helpers?.mail?.send
351
+ if (!sendHelper) {
352
+ return false
353
+ }
354
+
355
+ if (sendHelper.__soundingWrapped) {
356
+ originalSend = sendHelper.__soundingOriginal || originalSend
357
+ return true
358
+ }
359
+
360
+ originalSend = sendHelper
361
+ sails.helpers.mail.send = wrapSendHelper(sendHelper)
362
+ return true
363
+ }
364
+
365
+ function uninstall() {
366
+ if (!originalSend || !sails?.helpers?.mail) {
367
+ return false
368
+ }
369
+
370
+ sails.helpers.mail.send = originalSend
371
+ originalSend = null
372
+ return true
373
+ }
374
+
375
+ return {
376
+ install,
377
+ uninstall,
378
+ get installed() {
379
+ return Boolean(originalSend) && Boolean(sails?.helpers?.mail?.send?.__soundingWrapped)
380
+ },
381
+ }
382
+ }
383
+
384
+ module.exports = {
385
+ createMailCapture,
386
+ buildCapturedMail,
387
+ extractLinks,
388
+ normalizeList,
389
+ resolvePrimaryLink,
390
+ renderTemplatePreview,
391
+ }
@@ -0,0 +1,28 @@
1
+ function createMailbox() {
2
+ const messages = []
3
+
4
+ return {
5
+ capture(message) {
6
+ const normalized = {
7
+ capturedAt: new Date().toISOString(),
8
+ ...message,
9
+ }
10
+ messages.push(normalized)
11
+ return normalized
12
+ },
13
+
14
+ all() {
15
+ return [...messages]
16
+ },
17
+
18
+ latest() {
19
+ return messages.at(-1)
20
+ },
21
+
22
+ clear() {
23
+ messages.length = 0
24
+ },
25
+ }
26
+ }
27
+
28
+ module.exports = { createMailbox }