sounding 0.0.3 → 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,6 +1,21 @@
1
1
  const path = require('node:path')
2
2
  const url = require('node:url')
3
-
3
+ const { createSoundingError } = require('./create-error')
4
+ const { getTrialContext } = require('./trial-context')
5
+
6
+ const DEFAULT_MAIL_LAYOUT = 'mail'
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
+ */
4
19
  function normalizeList(value) {
5
20
  if (value === undefined || value === null || value === '') {
6
21
  return []
@@ -20,6 +35,10 @@ function normalizeList(value) {
20
35
  return [value]
21
36
  }
22
37
 
38
+ /**
39
+ * @param {string | undefined} html
40
+ * @returns {string[]}
41
+ */
23
42
  function extractLinks(html) {
24
43
  if (!html) {
25
44
  return []
@@ -29,6 +48,11 @@ function extractLinks(html) {
29
48
  return [...matches].map((match) => match[1])
30
49
  }
31
50
 
51
+ /**
52
+ * @param {AnyRecord} [inputs]
53
+ * @param {string[]} [links]
54
+ * @returns {string | undefined}
55
+ */
32
56
  function resolvePrimaryLink(inputs = {}, links = []) {
33
57
  const templateData = inputs.templateData || {}
34
58
  const preferredKeys = [
@@ -50,6 +74,10 @@ function resolvePrimaryLink(inputs = {}, links = []) {
50
74
  return links.find((link) => !/\/unsubscribe\b/i.test(link)) || links[0]
51
75
  }
52
76
 
77
+ /**
78
+ * @param {any} view
79
+ * @returns {Promise<string | undefined>}
80
+ */
53
81
  function createRenderViewPromise(view) {
54
82
  if (!view) {
55
83
  return Promise.resolve(undefined)
@@ -62,22 +90,28 @@ function createRenderViewPromise(view) {
62
90
  return Promise.resolve(view)
63
91
  }
64
92
 
65
- async function renderTemplatePreview(sails, {
66
- template,
67
- templateData = {},
68
- layout = 'layout-email',
69
- }) {
93
+ /**
94
+ * @param {SoundingSailsApp} sails
95
+ * @param {AnyRecord} [inputs]
96
+ * @param {AnyRecord} [options]
97
+ * @returns {Promise<string | undefined>}
98
+ */
99
+ async function renderTemplatePreview(sails, inputs = {}, options = {}) {
100
+ const { template, templateData = {} } = inputs
101
+
70
102
  if (!template || typeof sails?.renderView !== 'function') {
71
103
  return undefined
72
104
  }
73
105
 
106
+ const resolvedLayout = resolvePreviewLayout(sails, inputs, options)
74
107
  const emailTemplatePath = path.join('emails/', template)
108
+ /** @type {string | false} */
75
109
  let emailTemplateLayout = false
76
110
 
77
- if (layout) {
111
+ if (resolvedLayout) {
78
112
  emailTemplateLayout = path.relative(
79
113
  path.dirname(emailTemplatePath),
80
- path.resolve('layouts/', layout)
114
+ path.resolve('layouts/', resolvedLayout)
81
115
  )
82
116
  }
83
117
 
@@ -90,10 +124,50 @@ async function renderTemplatePreview(sails, {
90
124
  )
91
125
  }
92
126
 
127
+ /**
128
+ * @param {SoundingSailsApp} sails
129
+ * @returns {AnyRecord}
130
+ */
93
131
  function resolveMailConfig(sails) {
94
132
  return sails?.config?.mail || {}
95
133
  }
96
134
 
135
+ /**
136
+ * @param {SoundingSailsApp} sails
137
+ * @param {AnyRecord} [options]
138
+ * @returns {AnyRecord}
139
+ */
140
+ function resolveSoundingMailConfig(sails, options = {}) {
141
+ if (options.mailSettings) {
142
+ return options.mailSettings
143
+ }
144
+
145
+ const soundingConfig =
146
+ options.soundingConfig || options.config || sails?.config?.sounding || {}
147
+
148
+ return soundingConfig.mail || {}
149
+ }
150
+
151
+ /**
152
+ * @param {SoundingSailsApp} sails
153
+ * @param {AnyRecord} [inputs]
154
+ * @param {AnyRecord} [options]
155
+ * @returns {string | false | undefined}
156
+ */
157
+ function resolvePreviewLayout(sails, inputs = {}, options = {}) {
158
+ if (Object.prototype.hasOwnProperty.call(inputs, 'layout')) {
159
+ return inputs.layout
160
+ }
161
+
162
+ const mailSettings = resolveSoundingMailConfig(sails, options)
163
+
164
+ if (Object.prototype.hasOwnProperty.call(mailSettings, 'layout')) {
165
+ return mailSettings.layout
166
+ }
167
+
168
+ return DEFAULT_MAIL_LAYOUT
169
+ }
170
+
97
171
  function resolveMailerName(sails, inputs = {}) {
98
172
  return inputs.mailer || process.env.MAIL_MAILER || resolveMailConfig(sails).default
99
173
  }
@@ -125,10 +199,17 @@ function toErrorShape(error) {
125
199
  }
126
200
  }
127
201
 
128
- async function buildCapturedMail(sails, inputs = {}) {
202
+ /**
203
+ * @param {SoundingSailsApp} sails
204
+ * @param {AnyRecord} [inputs]
205
+ * @param {AnyRecord} [options]
206
+ * @returns {Promise<SoundingMailMessage>}
207
+ */
208
+ async function buildCapturedMail(sails, inputs = {}, options = {}) {
129
209
  const mailer = resolveMailerName(sails, inputs)
130
210
  const transport = resolveTransportName(sails, mailer)
131
- const html = await renderTemplatePreview(sails, inputs)
211
+ const layout = resolvePreviewLayout(sails, inputs, options)
212
+ const html = await renderTemplatePreview(sails, inputs, options)
132
213
  const links = extractLinks(html)
133
214
 
134
215
  return {
@@ -150,10 +231,14 @@ async function buildCapturedMail(sails, inputs = {}) {
150
231
  ctaUrl: resolvePrimaryLink(inputs, links),
151
232
  attachments: inputs.attachments || [],
152
233
  headers: inputs.headers || {},
153
- layout: inputs.layout === undefined ? 'layout-email' : inputs.layout,
234
+ layout,
154
235
  }
155
236
  }
156
237
 
238
+ /**
239
+ * @param {{ sails?: SoundingSailsApp, mailbox: SoundingMailbox, getConfig?: () => AnyRecord }} input
240
+ * @returns {SoundingMailCapture}
241
+ */
157
242
  function createMailCapture({ sails, mailbox, getConfig }) {
158
243
  let originalSend = null
159
244
 
@@ -164,15 +249,30 @@ function createMailCapture({ sails, mailbox, getConfig }) {
164
249
  return soundingConfig.mail || {}
165
250
  }
166
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
+
167
261
  function isEnabled() {
168
262
  return resolveMailSettings().capture !== false
169
263
  }
170
264
 
171
265
  function shouldPassthrough() {
172
- const settings = resolveMailSettings()
266
+ const settings = resolveCaptureTarget().mailSettings
173
267
  return settings.deliver === true || settings.mode === 'passthrough'
174
268
  }
175
269
 
270
+ function resolveMessageLayout(inputs = {}) {
271
+ return resolvePreviewLayout(sails, inputs, {
272
+ mailSettings: resolveCaptureTarget().mailSettings,
273
+ })
274
+ }
275
+
176
276
  function wrapDeferred(executor, onRejected) {
177
277
  let promise = Promise.resolve().then(executor)
178
278
 
@@ -250,7 +350,10 @@ function createMailCapture({ sails, mailbox, getConfig }) {
250
350
  return sendHelper.with.bind(sendHelper)
251
351
  }
252
352
 
253
- 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
+ })
254
357
  }
255
358
 
256
359
  function resolveWithInvoker(sendHelper) {
@@ -262,23 +365,31 @@ function createMailCapture({ sails, mailbox, getConfig }) {
262
365
  return sendHelper.bind(sendHelper)
263
366
  }
264
367
 
265
- 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
+ })
266
372
  }
267
373
 
268
374
  async function captureSuccessfulSend(inputs = {}) {
375
+ const target = resolveCaptureTarget()
376
+
269
377
  try {
270
- const message = await buildCapturedMail(sails, inputs)
271
- mailbox.capture({
378
+ const message = await buildCapturedMail(sails, inputs, {
379
+ mailSettings: target.mailSettings,
380
+ })
381
+ target.mailbox.capture({
272
382
  ...message,
273
383
  status: 'sent',
274
384
  })
275
385
  } catch (error) {
276
- mailbox.capture({
386
+ target.mailbox.capture({
277
387
  mailer: resolveMailerName(sails, inputs),
278
388
  transport: resolveTransportName(sails, resolveMailerName(sails, inputs)),
279
389
  to: normalizeList(inputs.to),
280
390
  subject: inputs.subject || '',
281
391
  template: inputs.template,
392
+ layout: resolveMessageLayout(inputs),
282
393
  status: 'sent',
283
394
  captureError: toErrorShape(error),
284
395
  })
@@ -286,10 +397,13 @@ function createMailCapture({ sails, mailbox, getConfig }) {
286
397
  }
287
398
 
288
399
  async function captureFailedSend(inputs = {}, error) {
400
+ const target = resolveCaptureTarget()
289
401
  let message
290
402
 
291
403
  try {
292
- message = await buildCapturedMail(sails, inputs)
404
+ message = await buildCapturedMail(sails, inputs, {
405
+ mailSettings: target.mailSettings,
406
+ })
293
407
  } catch (captureError) {
294
408
  message = {
295
409
  mailer: resolveMailerName(sails, inputs),
@@ -297,11 +411,12 @@ function createMailCapture({ sails, mailbox, getConfig }) {
297
411
  to: normalizeList(inputs.to),
298
412
  subject: inputs.subject || '',
299
413
  template: inputs.template,
414
+ layout: resolveMessageLayout(inputs),
300
415
  captureError: toErrorShape(captureError),
301
416
  }
302
417
  }
303
418
 
304
- mailbox.capture({
419
+ target.mailbox.capture({
305
420
  ...message,
306
421
  status: 'failed',
307
422
  error: toErrorShape(error),
@@ -347,27 +462,60 @@ function createMailCapture({ sails, mailbox, getConfig }) {
347
462
  return false
348
463
  }
349
464
 
350
- const sendHelper = sails?.helpers?.mail?.send
465
+ const mailHelpers = sails?.helpers?.mail
466
+ const sendHelper = mailHelpers?.send
351
467
  if (!sendHelper) {
352
468
  return false
353
469
  }
354
470
 
355
- if (sendHelper.__soundingWrapped) {
356
- 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)
357
476
  return true
358
477
  }
359
478
 
360
479
  originalSend = sendHelper
361
- 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
362
494
  return true
363
495
  }
364
496
 
365
497
  function uninstall() {
366
- if (!originalSend || !sails?.helpers?.mail) {
498
+ const mailHelpers = sails?.helpers?.mail
499
+
500
+ if (!originalSend || !mailHelpers) {
367
501
  return false
368
502
  }
369
503
 
370
- 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
371
519
  originalSend = null
372
520
  return true
373
521
  }
@@ -388,4 +536,5 @@ module.exports = {
388
536
  normalizeList,
389
537
  resolvePrimaryLink,
390
538
  renderTemplatePreview,
539
+ resolvePreviewLayout,
391
540
  }
@@ -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
  },