@live-change/email-service 0.9.221 → 0.9.223

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/index.js CHANGED
@@ -5,7 +5,7 @@ import definition from './definition.js'
5
5
 
6
6
  import './send.js'
7
7
  import './auth.js'
8
- import './notiifcations.js'
8
+ import './notifications.js'
9
9
 
10
10
  import validator from './emailValidator.js'
11
11
  definition.validator('email', validator)
@@ -0,0 +1,25 @@
1
+ export function escapeHtml(value) {
2
+ return String(value ?? '')
3
+ .replace(/&/g, '&')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ }
8
+
9
+ export function buildPlainEmailContent({ to, notificationType, title, message, notification }) {
10
+ const subject = title
11
+ ? String(title)
12
+ : `Notification: ${notificationType}`
13
+ const lines = [
14
+ message ? String(message) : `You have a new notification (${notificationType}).`,
15
+ '',
16
+ notification ? `Notification id: ${notification}` : null
17
+ ].filter(line => line != null)
18
+ const text = lines.join('\n')
19
+ const html = `<div style="font-family:sans-serif;line-height:1.4">
20
+ <h2 style="margin:0 0 12px">${escapeHtml(subject)}</h2>
21
+ <p>${escapeHtml(lines[0] || '')}</p>
22
+ ${notification ? `<p style="color:#666;font-size:12px">id: ${escapeHtml(String(notification))}</p>` : ''}
23
+ </div>`
24
+ return { to, subject, text, html }
25
+ }
@@ -0,0 +1,155 @@
1
+ import definition from './definition.js'
2
+ import { Email } from './auth.js'
3
+ import { buildPlainEmailContent } from './notificationEmailContent.js'
4
+
5
+ const CONTACT_TYPE = 'email_Email'
6
+ const Notification = definition.foreignModel('notification', 'Notification')
7
+
8
+ async function channelActive(trigger, {
9
+ userId,
10
+ contactId,
11
+ notificationType
12
+ }) {
13
+ try {
14
+ return await trigger({ type: 'isNotificationChannelActive' }, {
15
+ userId,
16
+ contactType: CONTACT_TYPE,
17
+ contactId,
18
+ notificationType
19
+ })
20
+ } catch {
21
+ // Prefer fail-open for delivery when preference service is unavailable
22
+ return true
23
+ }
24
+ }
25
+
26
+ async function sendNotificationEmail(trigger, {
27
+ userId,
28
+ notificationId,
29
+ notificationType,
30
+ title,
31
+ message
32
+ }) {
33
+ const emails = await Email.indexRangeGet('byUser', [userId], { limit: 32 }) ?? []
34
+ if (!emails.length) return { sent: 0, skipped: 0 }
35
+
36
+ let sent = 0
37
+ let skipped = 0
38
+
39
+ for (const row of emails) {
40
+ const contactId = row?.id ?? row?.to ?? row?.email
41
+ const address = row?.email || contactId
42
+ if (!contactId || !address) continue
43
+
44
+ const active = await channelActive(trigger, {
45
+ userId,
46
+ contactId: String(contactId),
47
+ notificationType
48
+ })
49
+ if (!active) {
50
+ skipped += 1
51
+ continue
52
+ }
53
+
54
+ const content = buildPlainEmailContent({
55
+ to: String(address),
56
+ notificationType,
57
+ title,
58
+ message,
59
+ notification: notificationId
60
+ })
61
+
62
+ await trigger({ type: 'sendEmailMessage' }, { email: content })
63
+ sent += 1
64
+ }
65
+
66
+ if (sent > 0 && notificationId) {
67
+ await trigger({ type: 'markNotificationsEmailed' }, {
68
+ user: userId,
69
+ notifications: [String(notificationId)]
70
+ }).catch(() => {})
71
+ } else if (notificationId && sent === 0 && emails.length) {
72
+ await trigger({ type: 'setNotificationChannelState' }, {
73
+ notification: String(notificationId),
74
+ emailState: 'pending'
75
+ }).catch(() => {})
76
+ }
77
+
78
+ return { sent, skipped }
79
+ }
80
+
81
+ definition.trigger({
82
+ name: 'notificationCreated',
83
+ properties: {
84
+ notification: { type: String },
85
+ sessionOrUserType: { type: String, validation: ['nonEmpty'] },
86
+ sessionOrUser: { type: String, validation: ['nonEmpty'] },
87
+ notificationType: { type: String, validation: ['nonEmpty'] },
88
+ title: { type: String },
89
+ message: { type: String },
90
+ time: { type: Date }
91
+ },
92
+ async execute(params, { trigger }) {
93
+ if (String(params.sessionOrUserType) !== 'user_User') return { skipped: true, reason: 'not_user' }
94
+ const userId = String(params.sessionOrUser)
95
+ const notificationId = params.notification != null
96
+ ? String(params.notification)
97
+ : null
98
+
99
+ if (notificationId) {
100
+ await trigger({ type: 'setNotificationChannelState' }, {
101
+ notification: notificationId,
102
+ emailState: 'pending'
103
+ }).catch(() => {})
104
+ }
105
+
106
+ return sendNotificationEmail(trigger, {
107
+ userId,
108
+ notificationId,
109
+ notificationType: String(params.notificationType),
110
+ title: params.title,
111
+ message: params.message
112
+ })
113
+ }
114
+ })
115
+
116
+ definition.trigger({
117
+ name: 'checkEmailNotificationState',
118
+ properties: {
119
+ sessionOrUserType: { type: String, validation: ['nonEmpty'] },
120
+ sessionOrUser: { type: String, validation: ['nonEmpty'] }
121
+ },
122
+ async execute({ sessionOrUserType, sessionOrUser }, { trigger }) {
123
+ if (String(sessionOrUserType) !== 'user_User') {
124
+ return { skipped: true, reason: 'not_user' }
125
+ }
126
+ const userId = String(sessionOrUser)
127
+ const rows = await Notification.indexRangeGet(
128
+ 'bySessionOrUser',
129
+ ['user_User', userId],
130
+ { limit: 64, reverse: true }
131
+ ) ?? []
132
+
133
+ const pending = rows.filter(row => {
134
+ const state = String(row?.emailState ?? '')
135
+ return state !== 'sent'
136
+ })
137
+
138
+ const results = []
139
+ for (const row of pending) {
140
+ const notificationId = row?.id ?? row?.to
141
+ if (!notificationId) continue
142
+ const result = await sendNotificationEmail(trigger, {
143
+ userId,
144
+ notificationId: String(notificationId),
145
+ notificationType: String(row.notificationType ?? 'unknown'),
146
+ title: row.title,
147
+ message: row.message
148
+ })
149
+ results.push({ notification: notificationId, ...result })
150
+ }
151
+ return { count: results.length, results }
152
+ }
153
+ })
154
+
155
+ export { buildPlainEmailContent } from './notificationEmailContent.js'
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Unit tests for plain notification email content builder.
3
+ */
4
+ import test from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { buildPlainEmailContent, escapeHtml } from './notificationEmailContent.js'
7
+
8
+ test('escapeHtml escapes markup', () => {
9
+ assert.equal(escapeHtml('<b>&"'), '&lt;b&gt;&amp;&quot;')
10
+ })
11
+
12
+ test('buildPlainEmailContent uses title and message', () => {
13
+ const content = buildPlainEmailContent({
14
+ to: 'a@test.com',
15
+ notificationType: 'ops_MaintenanceNeeded',
16
+ title: 'Maintenance needed',
17
+ message: 'Device offline',
18
+ notification: 'n1'
19
+ })
20
+ assert.equal(content.to, 'a@test.com')
21
+ assert.equal(content.subject, 'Maintenance needed')
22
+ assert.match(content.text, /Device offline/)
23
+ assert.match(content.html, /Maintenance needed/)
24
+ assert.match(content.html, /n1/)
25
+ })
26
+
27
+ test('buildPlainEmailContent falls back to notificationType subject', () => {
28
+ const content = buildPlainEmailContent({
29
+ to: 'a@test.com',
30
+ notificationType: 'example_TestNotification'
31
+ })
32
+ assert.equal(content.subject, 'Notification: example_TestNotification')
33
+ })
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@live-change/email-service",
3
- "version": "0.9.221",
3
+ "version": "0.9.223",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "NODE_ENV=test tape tests/*"
7
+ "test": "node --test notifications.test.mjs"
8
8
  },
9
9
  "repository": {
10
10
  "type": "git",
@@ -21,7 +21,7 @@
21
21
  "url": "https://www.viamage.com/"
22
22
  },
23
23
  "dependencies": {
24
- "@live-change/framework": "^0.9.221",
24
+ "@live-change/framework": "^0.9.223",
25
25
  "got": "^14.4.7",
26
26
  "html-to-text": "^9.0.5",
27
27
  "inline-css": "4.0.2",
@@ -32,6 +32,6 @@
32
32
  "postcss-calc": "10.1.1",
33
33
  "postcss-custom-properties": "14.0.4"
34
34
  },
35
- "gitHead": "ae6d6ee6938d3aaf0c2eda15fe19660d9a3cf105",
35
+ "gitHead": "bbe8401443d2e2a238362a9e73cd2697ddf2e6e9",
36
36
  "type": "module"
37
37
  }
package/notiifcations.js DELETED
@@ -1,50 +0,0 @@
1
- import App from '@live-change/framework'
2
- const app = App.app()
3
- import definition from './definition.js'
4
-
5
- definition.trigger({
6
- name: 'notificationCreated',
7
- properties: {
8
- notification: {
9
- type: Object
10
- },
11
- sessionOrUserType: {
12
- type: String,
13
- validation: ['nonEmpty']
14
- },
15
- sessionOrUser: {
16
- type: String,
17
- validation: ['nonEmpty']
18
- },
19
- notificationType: {
20
- type: String,
21
- validation: ['nonEmpty']
22
- }
23
- },
24
- async execute(params , { service }, emit) {
25
- /// TODO: think if this mechanism along with grouping could be moved to notification service
26
- /// TODO: check if user enabled email for this type of notification
27
- }
28
- })
29
-
30
- definition.trigger({
31
- name: 'checkEmailNotificationState',
32
- properties: {
33
- sessionOrUserType: {
34
- type: String,
35
- validation: ['nonEmpty']
36
- },
37
- sessionOrUser: {
38
- type: String,
39
- validation: ['nonEmpty']
40
- },
41
- },
42
- async execute(params , { service }, emit) {
43
-
44
- /// TODO: 1. get notifications by user
45
- /// TODO: 2. check if there are any notifications that are not emailed yed
46
- /// TODO: 3. decide if notifications should be grouped, partition notifications to grouped and non-grouped
47
- /// TODO: 4. send email for each group of notifications, and each non-grouped notification
48
- /// TODO: 5. mark notifications as emailed
49
- }
50
- })