@live-change/email-service 0.9.220 → 0.9.222
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 +1 -1
- package/notificationEmailContent.js +25 -0
- package/notifications.js +155 -0
- package/notifications.test.mjs +33 -0
- package/package.json +4 -4
- package/notiifcations.js +0 -50
package/index.js
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function escapeHtml(value) {
|
|
2
|
+
return String(value ?? '')
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
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
|
+
}
|
package/notifications.js
ADDED
|
@@ -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>&"'), '<b>&"')
|
|
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.
|
|
3
|
+
"version": "0.9.222",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
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.
|
|
24
|
+
"@live-change/framework": "^0.9.222",
|
|
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": "
|
|
35
|
+
"gitHead": "b513babdf0078deff02ebe43084aa59c592baa14",
|
|
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
|
-
})
|