feat(backend): chat notify via email (#8314)

* client

* backend

* tests

* also save awaySince timestamp

* remove console.log

* chat notification logic

* send notification mails for chat messages

* externalize online check, resolver resover first

* prevent email notifications for blocked users

comment

* respect user email notification settings

* properly handle null case for email destructuring

* tests

* corrected mail style

---------

Co-authored-by: mahula <lenzmath@posteo.de>
This commit is contained in:
Ulf Gebhardt 2025-04-07 12:23:36 +02:00 committed by GitHub
parent 4e827de29d
commit 2eaaa7af39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 393 additions and 2 deletions

View File

@ -6,6 +6,7 @@ import {
resetPasswordTemplate,
wrongAccountTemplate,
notificationTemplate,
chatMessageTemplate,
} from './templateBuilder'
const englishHint = 'English version below!'
@ -34,6 +35,12 @@ const resetPasswordTemplateData = () => ({
name: 'Mr Example',
},
})
const chatMessageTemplateData = {
email: 'test@example.org',
variables: {
name: 'Mr Example',
},
}
const wrongAccountTemplateData = () => ({
email: 'test@example.org',
variables: {},
@ -163,6 +170,31 @@ describe('templateBuilder', () => {
})
})
describe('chatMessageTemplate', () => {
describe('multi language', () => {
it('e-mail is build with all data', () => {
const subject = 'Neue Chatnachricht | New chat message'
const actionUrl = new URL('/chat', CONFIG.CLIENT_URI).toString()
const enContent = 'You have received a new chat message.'
const deContent = 'Du hast eine neue Chatnachricht erhalten.'
testEmailData(null, chatMessageTemplate, chatMessageTemplateData, [
...textsStandard,
{
templPropName: 'subject',
isContaining: false,
text: subject,
},
englishHint,
actionUrl,
chatMessageTemplateData.variables.name,
enContent,
deContent,
supportUrl,
])
})
})
})
describe('wrongAccountTemplate', () => {
describe('multi language', () => {
it('e-mail is build with all data', () => {

View File

@ -71,6 +71,19 @@ export const resetPasswordTemplate = ({ email, variables: { nonce, name } }) =>
}
}
export const chatMessageTemplate = ({ email, variables: { name } }) => {
const subject = 'Neue Chatnachricht | New chat message'
const actionUrl = new URL('/chat', CONFIG.CLIENT_URI)
const renderParams = { ...defaultParams, englishHint, actionUrl, name, subject }
return {
from,
to: email,
subject,
html: mustache.render(templates.layout, renderParams, { content: templates.chatMessage }),
}
}
export const wrongAccountTemplate = ({ email, _variables = {} }) => {
const subject = 'Falsche Mailadresse? | Wrong E-mail?'
const actionUrl = new URL('/password-reset/request', CONFIG.CLIENT_URI)

View File

@ -0,0 +1,105 @@
<!-- Email Body German : BEGIN -->
<table class="email-german" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
style="margin: auto;">
<!-- Hero Image, Flush : BEGIN -->
<tr>
<td style="background-color: #ffffff;">
<img
src="{{{ welcomeImageUrl }}}"
width="300" height="" alt="Welcome image" border="0"
style="width: 100%; max-width: 300px; height: auto; background: #ffffff; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; color: #555555; margin: auto; display: block; padding: 20px;"
class="g-img">
</td>
</tr>
<!-- Hero Image, Flush : END -->
<!-- 1 Column Text + Button : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<h1
style="margin: 0 0 10px 0; font-family: Lato, sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;">
Hallo {{ name }}!</h1>
<p style="margin: 0;">Du hast eine neue Chatnachricht erhalten.</p>
</td>
</tr>
<tr>
<td style="padding: 0 20px;">
<!-- Button : BEGIN -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
<tr>
<td class="button-td button-td-primary" style="border-radius: 4px; background: #17b53e;">
<a class="button-a button-a-primary" href="{{{ actionUrl }}}"
style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">Chat anzeigen</a>
</td>
</tr>
</table>
<!-- Button : END -->
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text + Button : END -->
</table>
<!-- Email Body German : END -->
<!-- Email Body English : BEGIN -->
<table class="email-english" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
style="margin: auto;">
<tr>
<td style="padding: 20px 0; text-align: center">
</td>
</tr>
<!-- Hero Image, Flush : BEGIN -->
<tr>
<td style="background-color: #ffffff;">
<img
src="{{{ welcomeImageUrl }}}"
width="300" height="" alt="Welcome image" border="0"
style="width: 100%; max-width: 300px; height: auto; background: #ffffff; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; color: #555555; margin: auto; display: block; padding: 20px;"
class="g-img">
</td>
</tr>
<!-- Hero Image, Flush : END -->
<!-- 1 Column Text + Button : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<h1
style="margin: 0 0 10px 0; font-family: Lato, sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;">
Hello {{ name }}!</h1>
<p style="margin: 0;">You have received a new chat message.</p>
</td>
</tr>
<tr>
<td style="padding: 0 20px;">
<!-- Button : BEGIN -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
<tr>
<td class="button-td button-td-primary" style="border-radius: 4px; background: #17b53e;">
<a class="button-a button-a-primary" href="{{{ actionUrl }}}"
style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">Show Chat</a>
</td>
</tr>
</table>
<!-- Button : END -->
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text + Button : END -->
</table>
<!-- Email Body English : END -->

View File

@ -7,5 +7,6 @@ export const signup = readFile('./signup.html')
export const passwordReset = readFile('./resetPassword.html')
export const wrongAccount = readFile('./wrongAccount.html')
export const emailVerification = readFile('./emailVerification.html')
export const chatMessage = readFile('./chatMessage.html')
export const layout = readFile('./layout.html')

View File

@ -0,0 +1,46 @@
import { isUserOnline } from './isUserOnline'
let user
describe('isUserOnline', () => {
beforeEach(() => {
user = {
properties: {
lastActiveAt: null,
awaySince: null,
lastOnlineStatus: null,
},
}
})
describe('user has lastOnlineStatus `online`', () => {
it('returns true if he was active within the last 90 seconds', () => {
user.properties.lastOnlineStatus = 'online'
user.properties.lastActiveAt = new Date()
expect(isUserOnline(user)).toBe(true)
})
it('returns false if he was not active within the last 90 seconds', () => {
user.properties.lastOnlineStatus = 'online'
user.properties.lastActiveAt = new Date().getTime() - 90001
expect(isUserOnline(user)).toBe(false)
})
})
describe('user has lastOnlineStatus `away`', () => {
it('returns true if he went away less then 180 seconds ago', () => {
user.properties.lastOnlineStatus = 'away'
user.properties.awaySince = new Date()
expect(isUserOnline(user)).toBe(true)
})
it('returns false if he went away more then 180 seconds ago', () => {
user.properties.lastOnlineStatus = 'away'
user.properties.awaySince = new Date().getTime() - 180001
expect(isUserOnline(user)).toBe(false)
})
})
describe('user is freshly created and has never logged in', () => {
it('returns false', () => {
expect(isUserOnline(user)).toBe(false)
})
})
})

View File

@ -0,0 +1,16 @@
export const isUserOnline = (user) => {
// Is Recipient considered online
const lastActive = new Date(user.properties.lastActiveAt).getTime()
const awaySince = new Date(user.properties.awaySince).getTime()
const now = new Date().getTime()
const status = user.properties.lastOnlineStatus
if (
// online & last active less than 1.5min -> online
(status === 'online' && now - lastActive < 90000) ||
// away for less then 3min -> online
(status === 'away' && now - awaySince < 180000)
) {
return true
}
return false
}

View File

@ -1,5 +1,5 @@
import gql from 'graphql-tag'
import { cleanDatabase } from '../../db/factories'
import Factory, { cleanDatabase } from '../../db/factories'
import { createTestClient } from 'apollo-server-testing'
import { getNeode, getDriver } from '../../db/neo4j'
import createServer, { pubsub } from '../../server'
@ -10,6 +10,23 @@ import {
changeGroupMemberRoleMutation,
removeUserFromGroupMutation,
} from '../../graphql/groups'
import { createMessageMutation } from '../../graphql/messages'
import { createRoomMutation } from '../../graphql/rooms'
const sendMailMock = jest.fn()
jest.mock('../helpers/email/sendMail', () => ({
sendMail: () => sendMailMock(),
}))
const chatMessageTemplateMock = jest.fn()
jest.mock('../helpers/email/templateBuilder', () => ({
chatMessageTemplate: () => chatMessageTemplateMock(),
}))
let isUserOnlineMock = jest.fn()
jest.mock('../helpers/isUserOnline', () => ({
isUserOnline: () => isUserOnlineMock(),
}))
let server, query, mutate, notifiedUser, authenticatedUser
let publishSpy
@ -633,6 +650,115 @@ describe('notifications', () => {
})
})
describe('chat email notifications', () => {
let chatSender
let chatReceiver
let roomId
beforeEach(async () => {
jest.clearAllMocks()
chatSender = await neode.create(
'User',
{
id: 'chatSender',
name: 'chatSender',
slug: 'chatSender',
},
{
email: 'chatSender@example.org',
password: '1234',
},
)
chatReceiver = await Factory.build(
'user',
{ id: 'chatReceiver', name: 'chatReceiver', slug: 'chatReceiver' },
{ email: 'user@example.org' },
)
authenticatedUser = await chatSender.toJson()
const room = await mutate({
mutation: createRoomMutation(),
variables: {
userId: 'chatReceiver',
},
})
roomId = room.data.CreateRoom.id
})
describe('chatReceiver is online', () => {
it('sends no email', async () => {
isUserOnlineMock = jest.fn().mockReturnValue(true)
await mutate({
mutation: createMessageMutation(),
variables: {
roomId,
content: 'Some nice message to chatReceiver',
},
})
expect(sendMailMock).not.toHaveBeenCalled()
expect(chatMessageTemplateMock).not.toHaveBeenCalled()
})
})
describe('chatReceiver is offline', () => {
it('sends an email', async () => {
isUserOnlineMock = jest.fn().mockReturnValue(false)
await mutate({
mutation: createMessageMutation(),
variables: {
roomId,
content: 'Some nice message to chatReceiver',
},
})
expect(sendMailMock).toHaveBeenCalledTimes(1)
expect(chatMessageTemplateMock).toHaveBeenCalledTimes(1)
})
})
describe('chatReceiver has blocked chatSender', () => {
it('sends no email', async () => {
isUserOnlineMock = jest.fn().mockReturnValue(false)
await chatReceiver.relateTo(chatSender, 'blocked')
await mutate({
mutation: createMessageMutation(),
variables: {
roomId,
content: 'Some nice message to chatReceiver',
},
})
expect(sendMailMock).not.toHaveBeenCalled()
expect(chatMessageTemplateMock).not.toHaveBeenCalled()
})
})
describe('chatReceiver has disabled email notifications', () => {
it('sends no email', async () => {
isUserOnlineMock = jest.fn().mockReturnValue(false)
await chatReceiver.update({ sendNotificationEmails: false })
await mutate({
mutation: createMessageMutation(),
variables: {
roomId,
content: 'Some nice message to chatReceiver',
},
})
expect(sendMailMock).not.toHaveBeenCalled()
expect(chatMessageTemplateMock).not.toHaveBeenCalled()
})
})
})
describe('group notifications', () => {
let groupOwner

View File

@ -2,7 +2,8 @@ import { pubsub, NOTIFICATION_ADDED } from '../../server'
import extractMentionedUsers from './mentions/extractMentionedUsers'
import { validateNotifyUsers } from '../validation/validationMiddleware'
import { sendMail } from '../helpers/email/sendMail'
import { notificationTemplate } from '../helpers/email/templateBuilder'
import { chatMessageTemplate, notificationTemplate } from '../helpers/email/templateBuilder'
import { isUserOnline } from '../helpers/isUserOnline'
const queryNotificationEmails = async (context, notificationUserIds) => {
if (!(notificationUserIds && notificationUserIds.length)) return []
@ -314,6 +315,56 @@ const notifyUsersOfComment = async (label, commentId, reason, context) => {
}
}
const handleCreateMessage = async (resolve, root, args, context, resolveInfo) => {
// Execute resolver
const result = await resolve(root, args, context, resolveInfo)
// Query Parameters
const { roomId } = args
const {
user: { id: currentUserId },
} = context
// Find Recipient
const session = context.driver.session()
const messageRecipient = session.readTransaction(async (transaction) => {
const messageRecipientCypher = `
MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room { id: $roomId })
MATCH (room)<-[:CHATS_IN]-(recipientUser:User)-[:PRIMARY_EMAIL]->(emailAddress:EmailAddress)
WHERE NOT recipientUser.id = $currentUserId
AND NOT (recipientUser)-[:BLOCKED]-(currentUser)
AND recipientUser.sendNotificationEmails = true
RETURN recipientUser, emailAddress {.email}
`
const txResponse = await transaction.run(messageRecipientCypher, {
currentUserId,
roomId,
})
return {
user: await txResponse.records.map((record) => record.get('recipientUser'))[0],
email: await txResponse.records.map((record) => record.get('emailAddress'))[0]?.email,
}
})
try {
// Execute Query
const { user, email } = await messageRecipient
// Send EMail if we found a user(not blocked) and he is not considered online
if (user && !isUserOnline(user)) {
void sendMail(chatMessageTemplate({ email, variables: { name: user.properties.name } }))
}
// Return resolver result to client
return result
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
}
export default {
Mutation: {
CreatePost: handleContentDataOfPost,
@ -324,5 +375,6 @@ export default {
LeaveGroup: handleLeaveGroup,
ChangeGroupMemberRole: handleChangeGroupMemberRole,
RemoveUserFromGroup: handleRemoveUserFromGroup,
CreateMessage: handleCreateMessage,
},
}