From 2eaaa7af3977307c0da47037ebf4b6c4a3544a51 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Mon, 7 Apr 2025 12:23:36 +0200 Subject: [PATCH] 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 --- .../helpers/email/templateBuilder.spec.ts | 32 +++++ .../helpers/email/templateBuilder.ts | 13 ++ .../helpers/email/templates/chatMessage.html | 105 ++++++++++++++ .../helpers/email/templates/index.ts | 1 + .../middleware/helpers/isUserOnline.spec.ts | 46 +++++++ .../src/middleware/helpers/isUserOnline.ts | 16 +++ .../notificationsMiddleware.spec.ts | 128 +++++++++++++++++- .../notifications/notificationsMiddleware.ts | 54 +++++++- 8 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 backend/src/middleware/helpers/email/templates/chatMessage.html create mode 100644 backend/src/middleware/helpers/isUserOnline.spec.ts create mode 100644 backend/src/middleware/helpers/isUserOnline.ts diff --git a/backend/src/middleware/helpers/email/templateBuilder.spec.ts b/backend/src/middleware/helpers/email/templateBuilder.spec.ts index cb516c0a9..437672a9a 100644 --- a/backend/src/middleware/helpers/email/templateBuilder.spec.ts +++ b/backend/src/middleware/helpers/email/templateBuilder.spec.ts @@ -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', () => { diff --git a/backend/src/middleware/helpers/email/templateBuilder.ts b/backend/src/middleware/helpers/email/templateBuilder.ts index 78d7a9bf9..431048336 100644 --- a/backend/src/middleware/helpers/email/templateBuilder.ts +++ b/backend/src/middleware/helpers/email/templateBuilder.ts @@ -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) diff --git a/backend/src/middleware/helpers/email/templates/chatMessage.html b/backend/src/middleware/helpers/email/templates/chatMessage.html new file mode 100644 index 000000000..0b1bacb08 --- /dev/null +++ b/backend/src/middleware/helpers/email/templates/chatMessage.html @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/middleware/helpers/email/templates/index.ts b/backend/src/middleware/helpers/email/templates/index.ts index b8ae01bdb..9c32c6d3e 100644 --- a/backend/src/middleware/helpers/email/templates/index.ts +++ b/backend/src/middleware/helpers/email/templates/index.ts @@ -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') diff --git a/backend/src/middleware/helpers/isUserOnline.spec.ts b/backend/src/middleware/helpers/isUserOnline.spec.ts new file mode 100644 index 000000000..bf2cb8d17 --- /dev/null +++ b/backend/src/middleware/helpers/isUserOnline.spec.ts @@ -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) + }) + }) +}) diff --git a/backend/src/middleware/helpers/isUserOnline.ts b/backend/src/middleware/helpers/isUserOnline.ts new file mode 100644 index 000000000..679953f81 --- /dev/null +++ b/backend/src/middleware/helpers/isUserOnline.ts @@ -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 +} diff --git a/backend/src/middleware/notifications/notificationsMiddleware.spec.ts b/backend/src/middleware/notifications/notificationsMiddleware.spec.ts index 57354d13f..50d655484 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.spec.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.spec.ts @@ -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 diff --git a/backend/src/middleware/notifications/notificationsMiddleware.ts b/backend/src/middleware/notifications/notificationsMiddleware.ts index aa2cee06e..7ecbf8181 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.ts @@ -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, }, }