sounding 0.0.4 → 0.1.0

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.
@@ -1,15 +1,41 @@
1
+ const { createSoundingError } = require('./create-error')
2
+
3
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
4
+ /** @typedef {import('./types').SoundingHelperRunner} SoundingHelperRunner */
5
+ /** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
6
+
7
+ /**
8
+ * @param {SoundingSailsApp} sails
9
+ * @param {string} identity
10
+ * @returns {any}
11
+ */
1
12
  function resolveHelper(sails, identity) {
2
13
  return identity
3
14
  .split('.')
4
15
  .reduce((current, segment) => current?.[segment], sails.helpers)
5
16
  }
6
17
 
18
+ /**
19
+ * @param {{ sails: SoundingSailsApp }} input
20
+ * @returns {SoundingHelperRunner}
21
+ */
7
22
  function createHelperRunner({ sails }) {
23
+ /**
24
+ * @param {string} identity
25
+ * @param {AnyRecord} [inputs]
26
+ * @returns {Promise<any>}
27
+ */
8
28
  async function invoke(identity, inputs = {}) {
9
29
  const helper = resolveHelper(sails, identity)
10
30
 
11
31
  if (!helper) {
12
- throw new Error(`Unknown Sounding helper: ${identity}`)
32
+ throw createSoundingError({
33
+ code: 'E_SOUNDING_HELPER_UNKNOWN',
34
+ message: `Unknown Sounding helper: ${identity}`,
35
+ details: {
36
+ identity,
37
+ },
38
+ })
13
39
  }
14
40
 
15
41
  if (typeof helper.with === 'function') {
@@ -20,9 +46,19 @@ function createHelperRunner({ sails }) {
20
46
  return helper(inputs)
21
47
  }
22
48
 
23
- throw new Error(`Sounding helper \`${identity}\` is not callable.`)
49
+ throw createSoundingError({
50
+ code: 'E_SOUNDING_HELPER_NOT_CALLABLE',
51
+ message: `Sounding helper \`${identity}\` is not callable.`,
52
+ details: {
53
+ identity,
54
+ },
55
+ })
24
56
  }
25
57
 
58
+ /**
59
+ * @param {string[]} [path]
60
+ * @returns {SoundingHelperRunner}
61
+ */
26
62
  function buildProxy(path = []) {
27
63
  const callable = async (...args) => {
28
64
  if (path.length === 0) {
@@ -1,8 +1,21 @@
1
1
  const path = require('node:path')
2
2
  const url = require('node:url')
3
+ const { createSoundingError } = require('./create-error')
4
+ const { getTrialContext } = require('./trial-context')
3
5
 
4
6
  const DEFAULT_MAIL_LAYOUT = 'mail'
5
-
7
+ const mailCaptureRegistry = new WeakMap()
8
+
9
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
10
+ /** @typedef {import('./types').SoundingMailCapture} SoundingMailCapture */
11
+ /** @typedef {import('./types').SoundingMailbox} SoundingMailbox */
12
+ /** @typedef {import('./types').SoundingMailMessage} SoundingMailMessage */
13
+ /** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
14
+
15
+ /**
16
+ * @param {any} value
17
+ * @returns {any[]}
18
+ */
6
19
  function normalizeList(value) {
7
20
  if (value === undefined || value === null || value === '') {
8
21
  return []
@@ -22,6 +35,10 @@ function normalizeList(value) {
22
35
  return [value]
23
36
  }
24
37
 
38
+ /**
39
+ * @param {string | undefined} html
40
+ * @returns {string[]}
41
+ */
25
42
  function extractLinks(html) {
26
43
  if (!html) {
27
44
  return []
@@ -31,6 +48,11 @@ function extractLinks(html) {
31
48
  return [...matches].map((match) => match[1])
32
49
  }
33
50
 
51
+ /**
52
+ * @param {AnyRecord} [inputs]
53
+ * @param {string[]} [links]
54
+ * @returns {string | undefined}
55
+ */
34
56
  function resolvePrimaryLink(inputs = {}, links = []) {
35
57
  const templateData = inputs.templateData || {}
36
58
  const preferredKeys = [
@@ -52,6 +74,10 @@ function resolvePrimaryLink(inputs = {}, links = []) {
52
74
  return links.find((link) => !/\/unsubscribe\b/i.test(link)) || links[0]
53
75
  }
54
76
 
77
+ /**
78
+ * @param {any} view
79
+ * @returns {Promise<string | undefined>}
80
+ */
55
81
  function createRenderViewPromise(view) {
56
82
  if (!view) {
57
83
  return Promise.resolve(undefined)
@@ -64,6 +90,12 @@ function createRenderViewPromise(view) {
64
90
  return Promise.resolve(view)
65
91
  }
66
92
 
93
+ /**
94
+ * @param {SoundingSailsApp} sails
95
+ * @param {AnyRecord} [inputs]
96
+ * @param {AnyRecord} [options]
97
+ * @returns {Promise<string | undefined>}
98
+ */
67
99
  async function renderTemplatePreview(sails, inputs = {}, options = {}) {
68
100
  const { template, templateData = {} } = inputs
69
101
 
@@ -73,6 +105,7 @@ async function renderTemplatePreview(sails, inputs = {}, options = {}) {
73
105
 
74
106
  const resolvedLayout = resolvePreviewLayout(sails, inputs, options)
75
107
  const emailTemplatePath = path.join('emails/', template)
108
+ /** @type {string | false} */
76
109
  let emailTemplateLayout = false
77
110
 
78
111
  if (resolvedLayout) {
@@ -91,10 +124,19 @@ async function renderTemplatePreview(sails, inputs = {}, options = {}) {
91
124
  )
92
125
  }
93
126
 
127
+ /**
128
+ * @param {SoundingSailsApp} sails
129
+ * @returns {AnyRecord}
130
+ */
94
131
  function resolveMailConfig(sails) {
95
132
  return sails?.config?.mail || {}
96
133
  }
97
134
 
135
+ /**
136
+ * @param {SoundingSailsApp} sails
137
+ * @param {AnyRecord} [options]
138
+ * @returns {AnyRecord}
139
+ */
98
140
  function resolveSoundingMailConfig(sails, options = {}) {
99
141
  if (options.mailSettings) {
100
142
  return options.mailSettings
@@ -106,6 +148,12 @@ function resolveSoundingMailConfig(sails, options = {}) {
106
148
  return soundingConfig.mail || {}
107
149
  }
108
150
 
151
+ /**
152
+ * @param {SoundingSailsApp} sails
153
+ * @param {AnyRecord} [inputs]
154
+ * @param {AnyRecord} [options]
155
+ * @returns {string | false | undefined}
156
+ */
109
157
  function resolvePreviewLayout(sails, inputs = {}, options = {}) {
110
158
  if (Object.prototype.hasOwnProperty.call(inputs, 'layout')) {
111
159
  return inputs.layout
@@ -151,6 +199,12 @@ function toErrorShape(error) {
151
199
  }
152
200
  }
153
201
 
202
+ /**
203
+ * @param {SoundingSailsApp} sails
204
+ * @param {AnyRecord} [inputs]
205
+ * @param {AnyRecord} [options]
206
+ * @returns {Promise<SoundingMailMessage>}
207
+ */
154
208
  async function buildCapturedMail(sails, inputs = {}, options = {}) {
155
209
  const mailer = resolveMailerName(sails, inputs)
156
210
  const transport = resolveTransportName(sails, mailer)
@@ -181,6 +235,10 @@ async function buildCapturedMail(sails, inputs = {}, options = {}) {
181
235
  }
182
236
  }
183
237
 
238
+ /**
239
+ * @param {{ sails?: SoundingSailsApp, mailbox: SoundingMailbox, getConfig?: () => AnyRecord }} input
240
+ * @returns {SoundingMailCapture}
241
+ */
184
242
  function createMailCapture({ sails, mailbox, getConfig }) {
185
243
  let originalSend = null
186
244
 
@@ -191,18 +249,27 @@ function createMailCapture({ sails, mailbox, getConfig }) {
191
249
  return soundingConfig.mail || {}
192
250
  }
193
251
 
252
+ function resolveCaptureTarget() {
253
+ const context = getTrialContext()
254
+
255
+ return {
256
+ mailbox: context?.mailbox || mailbox,
257
+ mailSettings: context?.getConfig?.().mail || resolveMailSettings(),
258
+ }
259
+ }
260
+
194
261
  function isEnabled() {
195
262
  return resolveMailSettings().capture !== false
196
263
  }
197
264
 
198
265
  function shouldPassthrough() {
199
- const settings = resolveMailSettings()
266
+ const settings = resolveCaptureTarget().mailSettings
200
267
  return settings.deliver === true || settings.mode === 'passthrough'
201
268
  }
202
269
 
203
270
  function resolveMessageLayout(inputs = {}) {
204
271
  return resolvePreviewLayout(sails, inputs, {
205
- mailSettings: resolveMailSettings(),
272
+ mailSettings: resolveCaptureTarget().mailSettings,
206
273
  })
207
274
  }
208
275
 
@@ -283,7 +350,10 @@ function createMailCapture({ sails, mailbox, getConfig }) {
283
350
  return sendHelper.with.bind(sendHelper)
284
351
  }
285
352
 
286
- throw new Error('Sounding could not invoke `sails.helpers.mail.send`.')
353
+ throw createSoundingError({
354
+ code: 'E_SOUNDING_MAIL_SEND_UNAVAILABLE',
355
+ message: 'Sounding could not invoke `sails.helpers.mail.send`.',
356
+ })
287
357
  }
288
358
 
289
359
  function resolveWithInvoker(sendHelper) {
@@ -295,20 +365,25 @@ function createMailCapture({ sails, mailbox, getConfig }) {
295
365
  return sendHelper.bind(sendHelper)
296
366
  }
297
367
 
298
- throw new Error('Sounding could not invoke `sails.helpers.mail.send.with()`.')
368
+ throw createSoundingError({
369
+ code: 'E_SOUNDING_MAIL_SEND_WITH_UNAVAILABLE',
370
+ message: 'Sounding could not invoke `sails.helpers.mail.send.with()`.',
371
+ })
299
372
  }
300
373
 
301
374
  async function captureSuccessfulSend(inputs = {}) {
375
+ const target = resolveCaptureTarget()
376
+
302
377
  try {
303
378
  const message = await buildCapturedMail(sails, inputs, {
304
- mailSettings: resolveMailSettings(),
379
+ mailSettings: target.mailSettings,
305
380
  })
306
- mailbox.capture({
381
+ target.mailbox.capture({
307
382
  ...message,
308
383
  status: 'sent',
309
384
  })
310
385
  } catch (error) {
311
- mailbox.capture({
386
+ target.mailbox.capture({
312
387
  mailer: resolveMailerName(sails, inputs),
313
388
  transport: resolveTransportName(sails, resolveMailerName(sails, inputs)),
314
389
  to: normalizeList(inputs.to),
@@ -322,11 +397,12 @@ function createMailCapture({ sails, mailbox, getConfig }) {
322
397
  }
323
398
 
324
399
  async function captureFailedSend(inputs = {}, error) {
400
+ const target = resolveCaptureTarget()
325
401
  let message
326
402
 
327
403
  try {
328
404
  message = await buildCapturedMail(sails, inputs, {
329
- mailSettings: resolveMailSettings(),
405
+ mailSettings: target.mailSettings,
330
406
  })
331
407
  } catch (captureError) {
332
408
  message = {
@@ -340,7 +416,7 @@ function createMailCapture({ sails, mailbox, getConfig }) {
340
416
  }
341
417
  }
342
418
 
343
- mailbox.capture({
419
+ target.mailbox.capture({
344
420
  ...message,
345
421
  status: 'failed',
346
422
  error: toErrorShape(error),
@@ -386,27 +462,60 @@ function createMailCapture({ sails, mailbox, getConfig }) {
386
462
  return false
387
463
  }
388
464
 
389
- const sendHelper = sails?.helpers?.mail?.send
465
+ const mailHelpers = sails?.helpers?.mail
466
+ const sendHelper = mailHelpers?.send
390
467
  if (!sendHelper) {
391
468
  return false
392
469
  }
393
470
 
394
- if (sendHelper.__soundingWrapped) {
395
- originalSend = sendHelper.__soundingOriginal || originalSend
471
+ const registered = mailCaptureRegistry.get(mailHelpers) || sendHelper.__soundingRegistry
472
+ if (registered && sendHelper === registered.wrappedSend) {
473
+ registered.installs += 1
474
+ originalSend = registered.originalSend
475
+ mailCaptureRegistry.set(mailHelpers, registered)
396
476
  return true
397
477
  }
398
478
 
399
479
  originalSend = sendHelper
400
- sails.helpers.mail.send = wrapSendHelper(sendHelper)
480
+ const wrappedSend = wrapSendHelper(sendHelper)
481
+ const registry = {
482
+ originalSend,
483
+ wrappedSend,
484
+ installs: 1,
485
+ }
486
+
487
+ Object.defineProperty(wrappedSend, '__soundingRegistry', {
488
+ value: registry,
489
+ configurable: true,
490
+ })
491
+
492
+ mailCaptureRegistry.set(mailHelpers, registry)
493
+ mailHelpers.send = wrappedSend
401
494
  return true
402
495
  }
403
496
 
404
497
  function uninstall() {
405
- if (!originalSend || !sails?.helpers?.mail) {
498
+ const mailHelpers = sails?.helpers?.mail
499
+
500
+ if (!originalSend || !mailHelpers) {
406
501
  return false
407
502
  }
408
503
 
409
- sails.helpers.mail.send = originalSend
504
+ const registered = mailCaptureRegistry.get(mailHelpers)
505
+
506
+ if (registered && registered.originalSend === originalSend) {
507
+ registered.installs -= 1
508
+
509
+ if (registered.installs <= 0) {
510
+ mailHelpers.send = originalSend
511
+ mailCaptureRegistry.delete(mailHelpers)
512
+ }
513
+
514
+ originalSend = null
515
+ return true
516
+ }
517
+
518
+ mailHelpers.send = originalSend
410
519
  originalSend = null
411
520
  return true
412
521
  }
@@ -1,7 +1,18 @@
1
+ /** @typedef {import('./types').SoundingMailbox} SoundingMailbox */
2
+ /** @typedef {import('./types').SoundingMailMessage} SoundingMailMessage */
3
+
4
+ /**
5
+ * @returns {SoundingMailbox}
6
+ */
1
7
  function createMailbox() {
8
+ /** @type {SoundingMailMessage[]} */
2
9
  const messages = []
3
10
 
4
11
  return {
12
+ /**
13
+ * @param {SoundingMailMessage} message
14
+ * @returns {SoundingMailMessage}
15
+ */
5
16
  capture(message) {
6
17
  const normalized = {
7
18
  capturedAt: new Date().toISOString(),
@@ -11,14 +22,23 @@ function createMailbox() {
11
22
  return normalized
12
23
  },
13
24
 
25
+ /**
26
+ * @returns {SoundingMailMessage[]}
27
+ */
14
28
  all() {
15
29
  return [...messages]
16
30
  },
17
31
 
32
+ /**
33
+ * @returns {SoundingMailMessage | undefined}
34
+ */
18
35
  latest() {
19
36
  return messages.at(-1)
20
37
  },
21
38
 
39
+ /**
40
+ * @returns {void}
41
+ */
22
42
  clear() {
23
43
  messages.length = 0
24
44
  },