feat: Allow to change subject for email transcript (#32792)
parent
05db8aa223
commit
b4bbcbfc9a
@ -0,0 +1,6 @@ |
||||
--- |
||||
"@rocket.chat/meteor": minor |
||||
"@rocket.chat/i18n": minor |
||||
--- |
||||
|
||||
Allows admins to customize the `Subject` field of Omnichannel email transcripts via setting. By passing a value to the setting `Custom email subject for transcript`, system will use it as the `Subject` field, unless a custom subject is passed when requesting a transcript. If there's no custom subject and setting value is empty, the current default value will be used |
||||
@ -0,0 +1,212 @@ |
||||
import { Message } from '@rocket.chat/core-services'; |
||||
import { |
||||
type IUser, |
||||
type MessageTypesValues, |
||||
type IOmnichannelSystemMessage, |
||||
isFileAttachment, |
||||
isFileImageAttachment, |
||||
} from '@rocket.chat/core-typings'; |
||||
import colors from '@rocket.chat/fuselage-tokens/colors'; |
||||
import { Logger } from '@rocket.chat/logger'; |
||||
import { LivechatRooms, LivechatVisitors, Messages, Uploads, Users } from '@rocket.chat/models'; |
||||
import { check } from 'meteor/check'; |
||||
import moment from 'moment-timezone'; |
||||
|
||||
import { callbacks } from '../../../../lib/callbacks'; |
||||
import { i18n } from '../../../../server/lib/i18n'; |
||||
import { FileUpload } from '../../../file-upload/server'; |
||||
import * as Mailer from '../../../mailer/server/api'; |
||||
import { settings } from '../../../settings/server'; |
||||
import { getTimezone } from '../../../utils/server/lib/getTimezone'; |
||||
|
||||
const logger = new Logger('Livechat-SendTranscript'); |
||||
|
||||
export async function sendTranscript({ |
||||
token, |
||||
rid, |
||||
email, |
||||
subject, |
||||
user, |
||||
}: { |
||||
token: string; |
||||
rid: string; |
||||
email: string; |
||||
subject?: string; |
||||
user?: Pick<IUser, '_id' | 'name' | 'username' | 'utcOffset'> | null; |
||||
}): Promise<boolean> { |
||||
check(rid, String); |
||||
check(email, String); |
||||
logger.debug(`Sending conversation transcript of room ${rid} to user with token ${token}`); |
||||
|
||||
const room = await LivechatRooms.findOneById(rid); |
||||
|
||||
const visitor = await LivechatVisitors.getVisitorByToken(token, { |
||||
projection: { _id: 1, token: 1, language: 1, username: 1, name: 1 }, |
||||
}); |
||||
|
||||
if (!visitor) { |
||||
throw new Error('error-invalid-token'); |
||||
} |
||||
|
||||
// @ts-expect-error - Visitor typings should include language?
|
||||
const userLanguage = visitor?.language || settings.get('Language') || 'en'; |
||||
const timezone = getTimezone(user); |
||||
logger.debug(`Transcript will be sent using ${timezone} as timezone`); |
||||
|
||||
if (!room) { |
||||
throw new Error('error-invalid-room'); |
||||
} |
||||
|
||||
// allow to only user to send transcripts from their own chats
|
||||
if (room.t !== 'l' || !room.v || room.v.token !== token) { |
||||
throw new Error('error-invalid-room'); |
||||
} |
||||
|
||||
const showAgentInfo = settings.get<boolean>('Livechat_show_agent_info'); |
||||
const closingMessage = await Messages.findLivechatClosingMessage(rid, { projection: { ts: 1 } }); |
||||
const ignoredMessageTypes: MessageTypesValues[] = [ |
||||
'livechat_navigation_history', |
||||
'livechat_transcript_history', |
||||
'command', |
||||
'livechat-close', |
||||
'livechat-started', |
||||
'livechat_video_call', |
||||
]; |
||||
const acceptableImageMimeTypes = ['image/jpeg', 'image/png', 'image/jpg']; |
||||
const messages = await Messages.findVisibleByRoomIdNotContainingTypesBeforeTs( |
||||
rid, |
||||
ignoredMessageTypes, |
||||
closingMessage?.ts ? new Date(closingMessage.ts) : new Date(), |
||||
{ |
||||
sort: { ts: 1 }, |
||||
}, |
||||
); |
||||
|
||||
let html = '<div> <hr>'; |
||||
const InvalidFileMessage = `<div style="background-color: ${colors.n100}; text-align: center; border-color: ${ |
||||
colors.n250 |
||||
}; border-width: 1px; border-style: solid; border-radius: 4px; padding-top: 8px; padding-bottom: 8px; margin-top: 4px;">${i18n.t( |
||||
'This_attachment_is_not_supported', |
||||
{ lng: userLanguage }, |
||||
)}</div>`;
|
||||
|
||||
for await (const message of messages) { |
||||
let author; |
||||
if (message.u._id === visitor._id) { |
||||
author = i18n.t('You', { lng: userLanguage }); |
||||
} else { |
||||
author = showAgentInfo ? message.u.name || message.u.username : i18n.t('Agent', { lng: userLanguage }); |
||||
} |
||||
|
||||
let messageContent = message.msg; |
||||
let filesHTML = ''; |
||||
|
||||
if (message.attachments && message.attachments?.length > 0) { |
||||
messageContent = message.attachments[0].description || ''; |
||||
|
||||
for await (const attachment of message.attachments) { |
||||
if (!isFileAttachment(attachment)) { |
||||
// ignore other types of attachments
|
||||
continue; |
||||
} |
||||
|
||||
if (!isFileImageAttachment(attachment)) { |
||||
filesHTML += `<div>${attachment.title || ''}${InvalidFileMessage}</div>`; |
||||
continue; |
||||
} |
||||
|
||||
if (!attachment.image_type || !acceptableImageMimeTypes.includes(attachment.image_type)) { |
||||
filesHTML += `<div>${attachment.title || ''}${InvalidFileMessage}</div>`; |
||||
continue; |
||||
} |
||||
|
||||
// Image attachment can be rendered in email body
|
||||
const file = message.files?.find((file) => file.name === attachment.title); |
||||
|
||||
if (!file) { |
||||
filesHTML += `<div>${attachment.title || ''}${InvalidFileMessage}</div>`; |
||||
continue; |
||||
} |
||||
|
||||
const uploadedFile = await Uploads.findOneById(file._id); |
||||
|
||||
if (!uploadedFile) { |
||||
filesHTML += `<div>${file.name}${InvalidFileMessage}</div>`; |
||||
continue; |
||||
} |
||||
|
||||
const uploadedFileBuffer = await FileUpload.getBuffer(uploadedFile); |
||||
filesHTML += `<div styles="color: ${colors.n700}; margin-top: 4px; flex-direction: "column";"><p>${file.name}</p><img src="data:${ |
||||
attachment.image_type |
||||
};base64,${uploadedFileBuffer.toString( |
||||
'base64', |
||||
)}" style="width: 400px; max-height: 240px; object-fit: contain; object-position: 0;"/></div>`;
|
||||
} |
||||
} |
||||
|
||||
const datetime = moment.tz(message.ts, timezone).locale(userLanguage).format('LLL'); |
||||
const singleMessage = ` |
||||
<p><strong>${author}</strong> <em>${datetime}</em></p> |
||||
<p>${messageContent}</p> |
||||
<p>${filesHTML}</p> |
||||
`;
|
||||
html += singleMessage; |
||||
} |
||||
|
||||
html = `${html}</div>`; |
||||
|
||||
const fromEmail = settings.get<string>('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); |
||||
let emailFromRegexp = ''; |
||||
if (fromEmail) { |
||||
emailFromRegexp = fromEmail[0]; |
||||
} else { |
||||
emailFromRegexp = settings.get<string>('From_Email'); |
||||
} |
||||
|
||||
// Some endpoints allow the caller to pass a different `subject` via parameter.
|
||||
// IF subject is passed, we'll use that one and treat it as an override
|
||||
// IF no subject is passed, we fallback to the setting `Livechat_transcript_email_subject`
|
||||
// IF that is not configured, we fallback to 'Transcript of your livechat conversation', which is the default value
|
||||
// As subject and setting value are user input, we don't translate them
|
||||
const mailSubject = |
||||
subject || |
||||
settings.get<string>('Livechat_transcript_email_subject') || |
||||
i18n.t('Transcript_of_your_livechat_conversation', { lng: userLanguage }); |
||||
|
||||
await Mailer.send({ |
||||
to: email, |
||||
from: emailFromRegexp, |
||||
replyTo: emailFromRegexp, |
||||
subject: mailSubject, |
||||
html, |
||||
}); |
||||
|
||||
setImmediate(() => { |
||||
void callbacks.run('livechat.sendTranscript', messages, email); |
||||
}); |
||||
|
||||
const requestData: IOmnichannelSystemMessage['requestData'] = { |
||||
type: 'user', |
||||
visitor, |
||||
user, |
||||
}; |
||||
|
||||
if (!user?.username) { |
||||
const cat = await Users.findOneById('rocket.cat', { projection: { _id: 1, username: 1, name: 1 } }); |
||||
if (cat) { |
||||
requestData.user = cat; |
||||
requestData.type = 'visitor'; |
||||
} |
||||
} |
||||
|
||||
if (!requestData.user) { |
||||
logger.error('rocket.cat user not found'); |
||||
throw new Error('No user provided and rocket.cat not found'); |
||||
} |
||||
|
||||
await Message.saveSystemMessage<IOmnichannelSystemMessage>('livechat_transcript_history', room._id, '', requestData.user, { |
||||
requestData, |
||||
}); |
||||
|
||||
return true; |
||||
} |
||||
@ -0,0 +1,205 @@ |
||||
import { expect } from 'chai'; |
||||
import p from 'proxyquire'; |
||||
import sinon from 'sinon'; |
||||
|
||||
const modelsMock = { |
||||
LivechatRooms: { |
||||
findOneById: sinon.stub(), |
||||
}, |
||||
LivechatVisitors: { |
||||
getVisitorByToken: sinon.stub(), |
||||
}, |
||||
Messages: { |
||||
findLivechatClosingMessage: sinon.stub(), |
||||
findVisibleByRoomIdNotContainingTypesBeforeTs: sinon.stub(), |
||||
}, |
||||
Users: { |
||||
findOneById: sinon.stub(), |
||||
}, |
||||
}; |
||||
|
||||
const checkMock = sinon.stub(); |
||||
|
||||
const mockLogger = class { |
||||
debug() { |
||||
return null; |
||||
} |
||||
|
||||
error() { |
||||
return null; |
||||
} |
||||
|
||||
warn() { |
||||
return null; |
||||
} |
||||
|
||||
info() { |
||||
return null; |
||||
} |
||||
}; |
||||
|
||||
const mockSettingValues: Record<string, any> = { |
||||
Livechat_show_agent_info: true, |
||||
Language: 'en', |
||||
From_Email: 'test@rocket.chat', |
||||
}; |
||||
|
||||
const settingsMock = function (key: string) { |
||||
return mockSettingValues[key] || null; |
||||
}; |
||||
|
||||
const getTimezoneMock = sinon.stub(); |
||||
|
||||
const mailerMock = sinon.stub(); |
||||
|
||||
const tStub = sinon.stub(); |
||||
|
||||
const { sendTranscript } = p.noCallThru().load('../../../../../../app/livechat/server/lib/sendTranscript', { |
||||
'@rocket.chat/models': modelsMock, |
||||
'@rocket.chat/logger': { Logger: mockLogger }, |
||||
'meteor/check': { check: checkMock }, |
||||
'../../../../lib/callbacks': { |
||||
callbacks: { |
||||
run: sinon.stub(), |
||||
}, |
||||
}, |
||||
'../../../../server/lib/i18n': { i18n: { t: tStub } }, |
||||
'../../../mailer/server/api': { send: mailerMock }, |
||||
'../../../settings/server': { settings: { get: settingsMock } }, |
||||
'../../../utils/server/lib/getTimezone': { getTimezone: getTimezoneMock }, |
||||
// TODO: add tests for file handling on transcripts
|
||||
'../../../file-upload/server': { FileUpload: {} }, |
||||
}); |
||||
|
||||
describe('Send transcript', () => { |
||||
beforeEach(() => { |
||||
checkMock.reset(); |
||||
modelsMock.LivechatRooms.findOneById.reset(); |
||||
modelsMock.LivechatVisitors.getVisitorByToken.reset(); |
||||
modelsMock.Messages.findLivechatClosingMessage.reset(); |
||||
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.reset(); |
||||
modelsMock.Users.findOneById.reset(); |
||||
mailerMock.reset(); |
||||
tStub.reset(); |
||||
}); |
||||
it('should throw error when rid or email are invalid params', async () => { |
||||
checkMock.throws(new Error('Invalid params')); |
||||
await expect(sendTranscript({})).to.be.rejectedWith(Error); |
||||
}); |
||||
it('should throw error when visitor not found', async () => { |
||||
modelsMock.LivechatVisitors.getVisitorByToken.resolves(null); |
||||
await expect(sendTranscript({ rid: 'rid', email: 'email', logger: mockLogger })).to.be.rejectedWith(Error); |
||||
}); |
||||
it('should attempt to send an email when params are valid using default subject', async () => { |
||||
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); |
||||
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } }); |
||||
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.resolves([]); |
||||
tStub.returns('Conversation Transcript'); |
||||
|
||||
await sendTranscript({ |
||||
rid: 'rid', |
||||
token: 'token', |
||||
email: 'email', |
||||
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' }, |
||||
}); |
||||
|
||||
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true; |
||||
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true; |
||||
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true; |
||||
expect( |
||||
mailerMock.calledWith({ |
||||
to: 'email', |
||||
from: 'test@rocket.chat', |
||||
subject: 'Conversation Transcript', |
||||
replyTo: 'test@rocket.chat', |
||||
html: '<div> <hr></div>', |
||||
}), |
||||
).to.be.true; |
||||
}); |
||||
it('should use provided subject', async () => { |
||||
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); |
||||
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } }); |
||||
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.resolves([]); |
||||
|
||||
await sendTranscript({ |
||||
rid: 'rid', |
||||
token: 'token', |
||||
email: 'email', |
||||
subject: 'A custom subject', |
||||
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' }, |
||||
}); |
||||
|
||||
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true; |
||||
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true; |
||||
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true; |
||||
expect( |
||||
mailerMock.calledWith({ |
||||
to: 'email', |
||||
from: 'test@rocket.chat', |
||||
subject: 'A custom subject', |
||||
replyTo: 'test@rocket.chat', |
||||
html: '<div> <hr></div>', |
||||
}), |
||||
).to.be.true; |
||||
}); |
||||
it('should use subject from setting (when configured) when no subject provided', async () => { |
||||
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); |
||||
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } }); |
||||
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.resolves([]); |
||||
mockSettingValues.Livechat_transcript_email_subject = 'A custom subject obtained from setting.get'; |
||||
|
||||
await sendTranscript({ |
||||
rid: 'rid', |
||||
token: 'token', |
||||
email: 'email', |
||||
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' }, |
||||
}); |
||||
|
||||
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true; |
||||
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true; |
||||
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true; |
||||
expect( |
||||
mailerMock.calledWith({ |
||||
to: 'email', |
||||
from: 'test@rocket.chat', |
||||
subject: 'A custom subject obtained from setting.get', |
||||
replyTo: 'test@rocket.chat', |
||||
html: '<div> <hr></div>', |
||||
}), |
||||
).to.be.true; |
||||
}); |
||||
it('should fail if room provided is invalid', async () => { |
||||
modelsMock.LivechatRooms.findOneById.resolves(null); |
||||
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); |
||||
|
||||
await expect(sendTranscript({ rid: 'rid', email: 'email', logger: mockLogger })).to.be.rejectedWith(Error); |
||||
}); |
||||
|
||||
it('should fail if room provided is of different type', async () => { |
||||
modelsMock.LivechatRooms.findOneById.resolves({ t: 'c' }); |
||||
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); |
||||
|
||||
await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error); |
||||
}); |
||||
|
||||
it('should fail if room is of valid type, but doesnt doesnt have `v` property', async () => { |
||||
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); |
||||
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l' }); |
||||
|
||||
await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error); |
||||
}); |
||||
|
||||
it('should fail if room is of valid type, has `v` prop, but it doesnt contain `token`', async () => { |
||||
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); |
||||
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { otherProp: 'xxx' } }); |
||||
|
||||
await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error); |
||||
}); |
||||
|
||||
it('should fail if room is of valid type, has `v.token`, but its different from the one on param (room from another visitor)', async () => { |
||||
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); |
||||
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'xxx' } }); |
||||
|
||||
await expect(sendTranscript({ rid: 'rid', email: 'email', token: 'xveasdf' })).to.be.rejectedWith(Error); |
||||
}); |
||||
}); |
||||
Loading…
Reference in new issue