diff --git a/.gitignore b/.gitignore index 10caae86e22..90c33dd4d34 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ gitinfo.json ecosystem.config.js nginx.conf private +engages-email-sender/src/__tests__/coverage/ +*.js.map diff --git a/api/src/__tests__/engageMessageQueries.test.ts b/api/src/__tests__/engageMessageQueries.test.ts index d4a186248c0..c5840e6838e 100644 --- a/api/src/__tests__/engageMessageQueries.test.ts +++ b/api/src/__tests__/engageMessageQueries.test.ts @@ -2,6 +2,7 @@ import * as sinon from 'sinon'; import { graphqlRequest } from '../db/connection'; import { brandFactory, + customerFactory, engageMessageFactory, segmentFactory, tagsFactory, @@ -205,6 +206,7 @@ describe('engageQueries', () => { }); test('Enage email delivery report list', async () => { + const customer = await customerFactory(); const dataSourceMock = sinon .stub(dataSources.EngagesAPI, 'engageReportsList') .callsFake(() => { @@ -213,9 +215,13 @@ describe('engageQueries', () => { { _id: '123', status: 'pending' + }, + { + _id: '234', + customerId: customer._id } ], - totalCount: 1 + totalCount: 2 }); }); @@ -243,7 +249,7 @@ describe('engageQueries', () => { { dataSources } ); - expect(response.list.length).toBe(1); + expect(response.list.length).toBe(2); dataSourceMock.restore(); }); @@ -459,4 +465,24 @@ describe('engageQueries', () => { expect(e[0].message).toBe('Engages api is not running'); } }); + + test('Test getAverageStats()', async () => { + const qry = ` + query engageEmailPercentages { + engageEmailPercentages { + avgBouncePercent + } + } + `; + + const mock = sinon + .stub(dataSources.EngagesAPI, 'getAverageStats') + .callsFake(() => { + return Promise.resolve({ data: { avgBouncePercent: 0 } }); + }); + + await graphqlRequest(qry, 'engageEmailPercentages', {}, { dataSources }); + + mock.restore(); + }); }); diff --git a/api/src/data/constants.ts b/api/src/data/constants.ts index 39e70c38cd6..0cdb4c56929 100644 --- a/api/src/data/constants.ts +++ b/api/src/data/constants.ts @@ -292,3 +292,25 @@ export const AUTO_BOT_MESSAGES = { export const BOT_MESSAGE_TYPES = { SAY_SOMETHING: 'say_something' }; + +export const AWS_EMAIL_STATUSES = { + SEND: 'send', + DELIVERY: 'delivery', + OPEN: 'open', + CLICK: 'click', + COMPLAINT: 'complaint', + BOUNCE: 'bounce', + RENDERING_FAILURE: 'renderingfailure', + REJECT: 'reject' +}; + +export const EMAIL_VALIDATION_STATUSES = { + VALID: 'valid', + INVALID: 'invalid', + ACCEPT_ALL_UNVERIFIABLE: 'accept_all_unverifiable', + UNVERIFIABLE: 'unverifiable', + UNKNOWN: 'unknown', + DISPOSABLE: 'disposable', + CATCH_ALL: 'catchall', + BAD_SYNTAX: 'badsyntax' +}; diff --git a/api/src/data/dataSources/engages.ts b/api/src/data/dataSources/engages.ts index daa6284fdd4..be3b4e31fee 100644 --- a/api/src/data/dataSources/engages.ts +++ b/api/src/data/dataSources/engages.ts @@ -90,4 +90,17 @@ export default class EngagesAPI extends RESTDataSource { return {}; } } + + // fetches average email delivery stat percentages + public async getAverageStats() { + try { + const response = await this.get(`/deliveryReports/avgStatPercentages`); + + return response; + } catch (e) { + debugBase(e); + + return { error: e.message }; + } + } } // end class diff --git a/api/src/data/modules/integrations/receiveMessage.ts b/api/src/data/modules/integrations/receiveMessage.ts index da90b3c527e..d9ee407fa87 100644 --- a/api/src/data/modules/integrations/receiveMessage.ts +++ b/api/src/data/modules/integrations/receiveMessage.ts @@ -8,6 +8,7 @@ import { } from '../../../db/models'; import { CONVERSATION_STATUSES } from '../../../db/models/definitions/constants'; import { graphqlPubsub } from '../../../pubsub'; +import { AWS_EMAIL_STATUSES, EMAIL_VALIDATION_STATUSES } from '../../constants'; import { getConfigs } from '../../utils'; const sendError = message => ({ @@ -172,10 +173,22 @@ export const receiveEngagesNotification = async msg => { const { action, data } = msg; if (action === 'setDoNotDisturb') { - await Customers.updateOne( - { _id: data.customerId }, - { $set: { doNotDisturb: 'Yes' } } - ); + const { customerId, status, customerIds = [] } = data; + const update: any = { doNotDisturb: 'Yes' }; + + if (status === AWS_EMAIL_STATUSES.BOUNCE) { + update.emailValidationStatus = EMAIL_VALIDATION_STATUSES.INVALID; + } + + if (customerId) { + await Customers.updateOne({ _id: customerId }, { $set: update }); + } + if (customerIds.length > 0 && !status) { + await Customers.updateMany( + { _id: { $in: customerIds } }, + { $set: update } + ); + } } if (action === 'transactionEmail') { diff --git a/api/src/data/resolvers/mutations/engageUtils.ts b/api/src/data/resolvers/mutations/engageUtils.ts index ddff9bfd92c..8b49e48a681 100644 --- a/api/src/data/resolvers/mutations/engageUtils.ts +++ b/api/src/data/resolvers/mutations/engageUtils.ts @@ -70,8 +70,8 @@ export const generateCustomerSelector = async ({ } return { - $or: [{ doNotDisturb: 'No' }, { doNotDisturb: { $exists: false } }], - ...customerQuery + ...customerQuery, + $or: [{ doNotDisturb: 'No' }, { doNotDisturb: { $exists: false } }] }; }; diff --git a/api/src/data/resolvers/queries/engages.ts b/api/src/data/resolvers/queries/engages.ts index be4e75a0fbd..5531f56b03a 100644 --- a/api/src/data/resolvers/queries/engages.ts +++ b/api/src/data/resolvers/queries/engages.ts @@ -1,4 +1,4 @@ -import { EngageMessages, Tags } from '../../../db/models'; +import { Customers, EngageMessages, Tags } from '../../../db/models'; import { IUserDocument } from '../../../db/models/definitions/users'; import { checkPermission, requireLogin } from '../../permissions/wrappers'; import { IContext } from '../../types'; @@ -32,6 +32,13 @@ interface ICountsByTag { [index: string]: number; } +interface IReportParams { + page?: number; + perPage?: number; + customerId?: string; + status?: string; +} + // basic count helper const count = async (selector: {}): Promise => { const res = await EngageMessages.find(selector).countDocuments(); @@ -224,8 +231,32 @@ const engageQueries = { return dataSources.EngagesAPI.engagesConfigDetail(); }, - engageReportsList(_root, params, { dataSources }: IContext) { - return dataSources.EngagesAPI.engageReportsList(params); + async engageReportsList( + _root, + params: IReportParams, + { dataSources }: IContext + ) { + const { + list = [], + totalCount + } = await dataSources.EngagesAPI.engageReportsList(params); + const modifiedList: any[] = []; + + for (const item of list) { + const modifiedItem = item; + + if (item.customerId) { + const customer = await Customers.findOne({ _id: item.customerId }); + + if (customer) { + modifiedItem.customerName = Customers.getCustomerName(customer); + } + } + + modifiedList.push(modifiedItem); + } + + return { totalCount, list: modifiedList }; }, /** @@ -246,12 +277,19 @@ const engageQueries = { */ engageVerifiedEmails(_root, _args, { dataSources }: IContext) { return dataSources.EngagesAPI.engagesGetVerifiedEmails(); + }, + + async engageEmailPercentages(_root, _args, { dataSources }: IContext) { + const response = await dataSources.EngagesAPI.getAverageStats(); + + return response.data; } }; requireLogin(engageQueries, 'engageMessagesTotalCount'); requireLogin(engageQueries, 'engageMessageCounts'); requireLogin(engageQueries, 'engageMessageDetail'); +requireLogin(engageQueries, 'engageEmailPercentages'); checkPermission(engageQueries, 'engageMessages', 'showEngagesMessages', []); diff --git a/api/src/data/schema/engage.ts b/api/src/data/schema/engage.ts index ed12aed1e0a..dc672288d3e 100644 --- a/api/src/data/schema/engage.ts +++ b/api/src/data/schema/engage.ts @@ -56,7 +56,8 @@ export const types = ` mailId: String, status: String, engage: EngageMessage, - createdAt: Date + createdAt: Date, + customerName: String } type EngageDeliveryReport { @@ -64,6 +65,18 @@ export const types = ` totalCount: Int } + type AvgEmailStats { + avgBouncePercent: Float, + avgClickPercent: Float, + avgComplaintPercent: Float, + avgDeliveryPercent: Float, + avgOpenPercent: Float, + avgRejectPercent: Float, + avgRenderingFailurePercent: Float, + avgSendPercent: Float, + total: Float + } + input EngageScheduleDateInput { type: String, month: String, @@ -114,7 +127,8 @@ export const queries = ` engageMessageCounts(name: String!, kind: String, status: String): JSON engagesConfigDetail: JSON engageVerifiedEmails: [String] - engageReportsList(page: Int, perPage: Int): EngageDeliveryReport + engageReportsList(page: Int, perPage: Int, customerId: String, status: String): EngageDeliveryReport + engageEmailPercentages: AvgEmailStats `; const commonParams = ` diff --git a/engages-email-sender/package.json b/engages-email-sender/package.json index 6759eefbdcf..ecc6c929c40 100644 --- a/engages-email-sender/package.json +++ b/engages-email-sender/package.json @@ -12,6 +12,7 @@ "@types/cors": "^2.8.4", "@types/dotenv": "^4.0.3", "@types/express": "^4.16.0", + "@types/faker": "^5.1.5", "@types/jest": "^24.0.23", "@types/mongodb": "^3.1.2", "@types/mongoose": "^5.2.1", @@ -44,4 +45,4 @@ "ts-node-dev": "^1.0.0-pre.32", "typescript": "^3.7.2" } -} \ No newline at end of file +} diff --git a/engages-email-sender/src/__tests__/deliveryReportsDb.test.ts b/engages-email-sender/src/__tests__/deliveryReportsDb.test.ts index 7771d2edcd5..2e4ade1aba6 100644 --- a/engages-email-sender/src/__tests__/deliveryReportsDb.test.ts +++ b/engages-email-sender/src/__tests__/deliveryReportsDb.test.ts @@ -1,4 +1,5 @@ import { DeliveryReports, Stats } from '../models'; +import { prepareAvgStats } from '../utils'; import { statsFactory } from './factories'; import './setup'; @@ -43,3 +44,21 @@ test('DeliveryReports: updateOrCreateReport', async done => { done(); }); + +test('Stats: Test average bounce & complaint percent', async done => { + const s1 = await statsFactory({}); + const s2 = await statsFactory({}); + const averageStat = await prepareAvgStats(); + + const bounce1 = (s1.bounce * 100) / s1.total; + const bounce2 = (s2.bounce * 100) / s2.total; + const complaint1 = (s1.complaint * 100) / s1.total; + const complaint2 = (s2.complaint * 100) / s2.total; + + expect(averageStat[0].avgBouncePercent).toBe((bounce1 + bounce2) / 2); + expect(averageStat[0].avgComplaintPercent).toBe( + (complaint1 + complaint2) / 2 + ); + + done(); +}); diff --git a/engages-email-sender/src/__tests__/factories.ts b/engages-email-sender/src/__tests__/factories.ts index 416f79c282f..0fc48e88c3f 100644 --- a/engages-email-sender/src/__tests__/factories.ts +++ b/engages-email-sender/src/__tests__/factories.ts @@ -1,5 +1,6 @@ import * as faker from 'faker'; -import { Configs, Stats } from '../models'; +import { SES_DELIVERY_STATUSES } from '../constants'; +import { Configs, DeliveryReports, Stats } from '../models'; /** * Returns random element of an array @@ -19,7 +20,7 @@ export const configFactory = params => { export const statsFactory = params => { const statsObj = new Stats({ - engageMessageId: params.engageMessageId || faker.random.id(), + engageMessageId: params.engageMessageId || faker.random.uuid(), open: params.open || faker.random.number(), click: params.click || faker.random.number(), complaint: params.complaint || faker.random.number(), @@ -33,3 +34,34 @@ export const statsFactory = params => { return statsObj.save(); }; + +export const generateCustomerDoc = (params: any = {}) => ({ + _id: faker.random.uuid(), + primaryEmail: params.email || faker.internet.email(), + emailValidationStatus: 'valid', + primaryPhone: faker.phone.phoneNumber(), + phoneValidationStatus: 'valid', + replacers: [{ key: 'key', value: 'value' }] +}); + +export const reportFactory = (params: any) => { + const report = new DeliveryReports({ + customerId: params.customerId || faker.random.uuid(), + mailId: params.mailId || faker.random.uuid(), + status: params.status || randomElementOfArray(SES_DELIVERY_STATUSES.ALL), + engageMessageId: params.engageMesageId || faker.random.uuid(), + email: params.email || faker.internet.email() + }); + + return report.save(); +}; + +export const generateCustomerDocList = (count: number) => { + const list: any[] = []; + + for (let i = 0; i < count; i++) { + list.push(generateCustomerDoc()); + } + + return list; +}; diff --git a/engages-email-sender/src/api/deliveryReports.ts b/engages-email-sender/src/api/deliveryReports.ts index a7d9fba2462..12827b9aaa7 100644 --- a/engages-email-sender/src/api/deliveryReports.ts +++ b/engages-email-sender/src/api/deliveryReports.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import { prepareSmsStats } from '../telnyxUtils'; +import { prepareAvgStats } from '../utils'; const router = Router(); @@ -33,12 +34,21 @@ router.get('/smsStats/:engageMessageId', async (req, res) => { router.get('/reportsList', async (req, res) => { debugRequest(debugEngages, req); - const { page, perPage } = req.query; + const { page, perPage, customerId, status } = req.query; const _page = Number(page || '1'); const _limit = Number(perPage || '20'); - const deliveryReports = await DeliveryReports.find() + const filter: any = {}; + + if (customerId) { + filter.customerId = customerId; + } + if (status) { + filter.status = status; + } + + const deliveryReports = await DeliveryReports.find(filter) .limit(_limit) .skip((_page - 1) * _limit) .sort({ createdAt: -1 }); @@ -77,4 +87,12 @@ router.get(`/logs/:engageMessageId`, async (req, res) => { return res.json(logs); }); +router.get('/avgStatPercentages', async (req: any, res) => { + debugRequest(debugEngages, req); + + const stats = await prepareAvgStats(); + + return res.json({ data: stats[0] }); +}); + export default router; diff --git a/engages-email-sender/src/constants.ts b/engages-email-sender/src/constants.ts index bc6734c3e75..d968b2e9189 100644 --- a/engages-email-sender/src/constants.ts +++ b/engages-email-sender/src/constants.ts @@ -48,3 +48,24 @@ export const SMS_DELIVERY_STATUSES = { } ] }; + +export const SES_DELIVERY_STATUSES = { + SEND: 'send', + DELIVERY: 'delivery', + OPEN: 'open', + CLICK: 'click', + COMPLAINT: 'complaint', + BOUNCE: 'bounce', + RENDERING_FAILURE: 'renderingfailure', + REJECT: 'reject', + ALL: [ + 'bounce', + 'click', + 'complaint', + 'delivery', + 'open', + 'reject', + 'renderingfailure', + 'send' + ] +}; diff --git a/engages-email-sender/src/models/DeliveryReports.ts b/engages-email-sender/src/models/DeliveryReports.ts index 8704973f18c..860228ca4ce 100644 --- a/engages-email-sender/src/models/DeliveryReports.ts +++ b/engages-email-sender/src/models/DeliveryReports.ts @@ -16,17 +16,57 @@ export interface IStats { export interface IStatsDocument extends IStats, Document {} export const statsSchema = new Schema({ - engageMessageId: { type: String }, + engageMessageId: { + type: String, + label: 'Engage message id at erxes-api', + unique: true + }, createdAt: { type: Date, default: new Date() }, - open: { type: Number, default: 0 }, - click: { type: Number, default: 0 }, - complaint: { type: Number, default: 0 }, - delivery: { type: Number, default: 0 }, - bounce: { type: Number, default: 0 }, - reject: { type: Number, default: 0 }, - send: { type: Number, default: 0 }, - renderingfailure: { type: Number, default: 0 }, - total: { type: Number, default: 0 } + open: { + type: Number, + default: 0, + label: + 'The recipient received the message and opened it in their email client' + }, + click: { + type: Number, + default: 0, + label: 'The recipient clicked one or more links in the email' + }, + complaint: { + type: Number, + default: 0, + label: + 'The email was successfully delivered to the recipient. The recipient marked the email as spam' + }, + delivery: { + type: Number, + default: 0, + label: `Amazon SES successfully delivered the email to the recipient's mail server` + }, + bounce: { + type: Number, + default: 0, + label: `The recipient's mail server permanently rejected the email` + }, + reject: { + type: Number, + default: 0, + label: + 'Amazon SES accepted the email, determined that it contained a virus, and rejected it' + }, + send: { + type: Number, + default: 0, + label: + 'The call to Amazon SES was successful and Amazon SES will attempt to deliver the email' + }, + renderingfailure: { + type: Number, + default: 0, + label: `The email wasn't sent because of a template rendering issue` + }, + total: { type: Number, default: 0, label: 'Total of all cases above' } }); export interface IDeliveryReports { @@ -34,16 +74,22 @@ export interface IDeliveryReports { mailId: string; status: string; customerId: string; + email?: string; } export interface IDeliveryReportsDocument extends IDeliveryReports, Document {} export const deliveryReportsSchema = new Schema({ - customerId: { type: String }, - mailId: { type: String, optional: true }, - status: { type: String, optional: true }, - engageMessageId: { type: String, optional: true }, - createdAt: { type: Date } + customerId: { type: String, label: 'Customer id at erxes-api' }, + mailId: { type: String, optional: true, label: 'AWS SES mail id' }, + status: { type: String, optional: true, label: 'Delivery status' }, + engageMessageId: { + type: String, + optional: true, + label: 'Engage message id at erxes-api' + }, + createdAt: { type: Date, label: 'Created at', default: new Date() }, + email: { type: String, label: 'Customer email' } }); export interface IStatsModel extends Model { @@ -80,7 +126,7 @@ export const loadDeliveryReportsClass = () => { * Change delivery report status */ public static async updateOrCreateReport(headers: any, status: string) { - const { engageMessageId, mailId, customerId } = headers; + const { engageMessageId, mailId, customerId, email } = headers; const deliveryReports = await DeliveryReports.findOne({ engageMessageId @@ -96,7 +142,8 @@ export const loadDeliveryReportsClass = () => { customerId, mailId, engageMessageId, - status + status, + email }); } diff --git a/engages-email-sender/src/sender.ts b/engages-email-sender/src/sender.ts index ccafe7fc3de..0c048fe6532 100644 --- a/engages-email-sender/src/sender.ts +++ b/engages-email-sender/src/sender.ts @@ -3,7 +3,13 @@ import * as Random from 'meteor-random'; import { debugEngages } from './debuggers'; import { Logs, SmsRequests, Stats } from './models'; import { getTelnyxInfo } from './telnyxUtils'; -import { createTransporter, getConfigs, getEnv, ICustomer } from './utils'; +import { + cleanIgnoredCustomers, + createTransporter, + getConfigs, + getEnv, + ICustomer +} from './utils'; dotenv.config(); @@ -138,6 +144,7 @@ export const start = async (data: { }) => { const { fromEmail, email, engageMessageId, customers } = data; const { content, subject, attachments, sender, replyTo } = email; + const configs = await getConfigs(); await Stats.findOneAndUpdate( { engageMessageId }, @@ -163,7 +170,7 @@ export const start = async (data: { const MAIN_API_DOMAIN = getEnv({ name: 'MAIN_API_DOMAIN' }); - const unSubscribeUrl = `${MAIN_API_DOMAIN}/unsubscribe/?cid=${customer._id}`; + const unsubscribeUrl = `${MAIN_API_DOMAIN}/unsubscribe/?cid=${customer._id}`; // replace customer attributes ===== let replacedContent = content; @@ -175,7 +182,7 @@ export const start = async (data: { } } - replacedContent += `
If you want to use service like this click here to read more. Also you can opt out from our email subscription here.
© 2020 erxes inc Growth Marketing Platform
`; + replacedContent += `
If you want to use service like this click here to read more. Also you can opt out from our email subscription here.
© 2021 erxes inc Growth Marketing Platform
`; try { await transporter.sendMail({ @@ -186,7 +193,7 @@ export const start = async (data: { attachments: mailAttachment, html: replacedContent, headers: { - 'X-SES-CONFIGURATION-SET': 'erxes', + 'X-SES-CONFIGURATION-SET': configs.configSet || 'erxes', EngageMessageId: engageMessageId, CustomerId: customer._id, MailMessageId: mailMessageId @@ -207,7 +214,6 @@ export const start = async (data: { await Stats.updateOne({ engageMessageId }, { $inc: { total: 1 } }); }; - const configs = await getConfigs(); const unverifiedEmailsLimit = parseInt( configs.unverifiedEmailsLimit || '100', 10 @@ -243,7 +249,13 @@ export const start = async (data: { ); } - for (const customer of filteredCustomers) { + // cleans customers who do not open or click emails often + const cleanCustomers = await cleanIgnoredCustomers({ + customers: filteredCustomers, + engageMessageId + }); + + for (const customer of cleanCustomers) { await new Promise(resolve => { setTimeout(resolve, 1000); }); diff --git a/engages-email-sender/src/trackers/engageTracker.ts b/engages-email-sender/src/trackers/engageTracker.ts index 5be8ed5af76..3470abe7740 100644 --- a/engages-email-sender/src/trackers/engageTracker.ts +++ b/engages-email-sender/src/trackers/engageTracker.ts @@ -20,7 +20,7 @@ export const getApi = async (type: string): Promise => { return new AWS.SNS(); }; -/* +/** * Receives notification from amazon simple notification service * And updates engage message status and stats */ @@ -48,6 +48,8 @@ const handleMessage = async message => { header => header.name === 'Emaildeliveryid' ); + const to = headers.find(header => header.name === 'To'); + const type = eventType.toLowerCase(); if (emailDeliveryId) { @@ -60,7 +62,8 @@ const handleMessage = async message => { const mailHeaders = { engageMessageId: engageMessageId && engageMessageId.value, mailId: mailId && mailId.value, - customerId: customerId && customerId.value + customerId: customerId && customerId.value, + email: to && to.value }; await Stats.updateStats(mailHeaders.engageMessageId, type); @@ -73,7 +76,7 @@ const handleMessage = async message => { if (rejected === 'reject') { await messageBroker().sendMessage('engagesNotification', { action: 'setDoNotDisturb', - data: { customerId: mailHeaders.customerId } + data: { customerId: mailHeaders.customerId, status: type } }); } diff --git a/engages-email-sender/src/utils.ts b/engages-email-sender/src/utils.ts index 92d66caa834..b2b0b98c03e 100644 --- a/engages-email-sender/src/utils.ts +++ b/engages-email-sender/src/utils.ts @@ -1,7 +1,10 @@ import * as AWS from 'aws-sdk'; import * as nodemailer from 'nodemailer'; +import { SES_DELIVERY_STATUSES } from './constants'; import { debugBase } from './debuggers'; +import messageBroker from './messageBroker'; import Configs, { ISESConfig } from './models/Configs'; +import { DeliveryReports, Stats } from './models/index'; import { getApi } from './trackers/engageTracker'; export const createTransporter = async () => { @@ -29,6 +32,11 @@ export interface IUser { email: string; } +interface ICustomerAnalyzeParams { + customers: ICustomer[]; + engageMessageId: string; +} + export const getEnv = ({ name, defaultValue @@ -173,3 +181,90 @@ export const getConfig = async (code, defaultValue?) => { return configs[code]; }; + +export const cleanIgnoredCustomers = async ({ + customers, + engageMessageId +}: ICustomerAnalyzeParams) => { + const customerIds = customers.map(c => c._id); + const ignoredCustomerIds: string[] = []; + + const allowedEmailSkipLimit = await getConfig('allowedEmailSkipLimit', '5'); + + // gather customers who did not open or click previously + const deliveries = await DeliveryReports.aggregate([ + { + $match: { + engageMessageId: { $ne: engageMessageId }, + customerId: { $in: customerIds }, + status: { + $nin: [SES_DELIVERY_STATUSES.OPEN, SES_DELIVERY_STATUSES.CLICK] + } + } + }, + { + $group: { _id: '$customerId', count: { $sum: 1 } } + } + ]); + + for (const delivery of deliveries) { + if (delivery.count > parseInt(allowedEmailSkipLimit, 10)) { + ignoredCustomerIds.push(delivery._id); + } + } + + if (ignoredCustomerIds.length > 0) { + await messageBroker().sendMessage('engagesNotification', { + action: 'setDoNotDisturb', + data: { customerIds: ignoredCustomerIds } + }); + + return customers.filter(c => ignoredCustomerIds.indexOf(c._id) === -1); + } + + return customers; +}; + +const getAvgCondition = (fieldName: string) => ({ + $cond: [ + { $gt: [`$${fieldName}`, 0] }, + { $divide: [{ $multiply: [`$${fieldName}`, 100] }, '$total'] }, + 0 + ] +}); + +// Prepares average engage stats of email delivery stats +export const prepareAvgStats = () => { + return Stats.aggregate([ + { + $match: { total: { $gt: 0 } } + }, + { + $project: { + createdAt: '$createdAt', + engageMessageId: '$engageMessageId', + pctBounce: getAvgCondition('bounce'), + pctClick: getAvgCondition('click'), + pctComplaint: getAvgCondition('complaint'), + pctDelivery: getAvgCondition('delivery'), + pctOpen: getAvgCondition('open'), + pctReject: getAvgCondition('reject'), + pctRenderingFailure: getAvgCondition('renderingfailure'), + pctSend: getAvgCondition('send') + } + }, + { + $group: { + _id: null, + avgBouncePercent: { $avg: '$pctBounce' }, + avgComplaintPercent: { $avg: '$pctComplaint' }, + avgClickPercent: { $avg: '$pctClick' }, + avgDeliveryPercent: { $avg: '$pctDelivery' }, + avgOpenPercent: { $avg: '$pctOpen' }, + avgRejectPercent: { $avg: '$pctReject' }, + avgRenderingFailurePercent: { $avg: '$pctRenderingFailure' }, + avgSendPercent: { $avg: '$pctSend' } + } + } + ]); +}; diff --git a/engages-email-sender/tsconfig.json b/engages-email-sender/tsconfig.json index 3ccce0f7eec..5a2c836d5ee 100644 --- a/engages-email-sender/tsconfig.json +++ b/engages-email-sender/tsconfig.json @@ -1,13 +1,14 @@ { "compilerOptions": { + "outDir": "./dist", "allowJs": true, "target": "es6", "moduleResolution": "node", "module": "commonjs", "lib": ["es2015", "es6", "es7", "esnext.asynciterable"], - "sourceMap": true, "noUnusedLocals": true, "noUnusedParameters": true, + "noImplicitAny": false, "skipLibCheck": true }, "include": ["./src/**/*"], diff --git a/engages-email-sender/yarn.lock b/engages-email-sender/yarn.lock index c064153de01..5577d5979c2 100644 --- a/engages-email-sender/yarn.lock +++ b/engages-email-sender/yarn.lock @@ -399,6 +399,11 @@ "@types/express-serve-static-core" "*" "@types/serve-static" "*" +"@types/faker@^5.1.5": + version "5.1.5" + resolved "https://registry.yarnpkg.com/@types/faker/-/faker-5.1.5.tgz#f14b015e0100232bb00c6dd7611505efb08709a0" + integrity sha512-2uEQFb7bsx68rqD4F8q95wZq6LTLOyexjv6BnvJogCO4jStkyc6IDEkODPQcWfovI6g6M3uPQ2/uD/oedJKkNw== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" diff --git a/ui/src/modules/auth/types.ts b/ui/src/modules/auth/types.ts index 9a17ec47734..85d9927fc87 100644 --- a/ui/src/modules/auth/types.ts +++ b/ui/src/modules/auth/types.ts @@ -6,7 +6,7 @@ import { IUserLinks as IUserLinksC } from 'erxes-ui/lib/auth/types'; -export type IUser = IUserC; +export type IUser = IUserC & { doNotDisturb?: boolean }; export type IUserDetails = IUserDetailsC; export type IUserLinks = IUserLinksC; export type IUserConversation = IUserConversationC; diff --git a/ui/src/modules/conformity/types.ts b/ui/src/modules/conformity/types.ts index 678427dc8a1..19e2bf81202 100644 --- a/ui/src/modules/conformity/types.ts +++ b/ui/src/modules/conformity/types.ts @@ -1,14 +1,14 @@ import { - EditConformityMutation as EditConformityMutationC, - EditConformityVariables as EditConformityVariablesC, - IConformityEdit as IConformityEditC, - ISavedConformity as ISavedConformityC + EditConformityMutation as EditConformityMutationC, + EditConformityVariables as EditConformityVariablesC, + IConformityEdit as IConformityEditC, + ISavedConformity as ISavedConformityC } from 'erxes-ui/lib/conformity/types'; -export type ISavedConformity = ISavedConformityC +export type ISavedConformity = ISavedConformityC; -export type IConformityEdit = IConformityEditC +export type IConformityEdit = IConformityEditC; -export type EditConformityVariables = EditConformityVariablesC +export type EditConformityVariables = EditConformityVariablesC; -export type EditConformityMutation = EditConformityMutationC +export type EditConformityMutation = EditConformityMutationC; diff --git a/ui/src/modules/engage/components/EngageStatItem.tsx b/ui/src/modules/engage/components/EngageStatItem.tsx new file mode 100644 index 00000000000..b8aac81f546 --- /dev/null +++ b/ui/src/modules/engage/components/EngageStatItem.tsx @@ -0,0 +1,73 @@ +import HelpPopover from 'modules/common/components/HelpPopover'; +import Icon from 'modules/common/components/Icon'; +import React from 'react'; +import s from 'underscore.string'; +import { + AWS_EMAIL_DELIVERY_STATUSES, + METHODS, + SMS_DELIVERY_STATUSES +} from '../constants'; +import { Box, BoxContent, BoxHeader, IconContainer } from '../styles'; + +type Props = { + count: number; + method: string; + totalCount?: number; + kind?: string; +}; + +export default function EngageStatItem({ + count, + kind, + method, + totalCount +}: Props) { + let percent = 0; + + if (count && totalCount) { + percent = (count * 100) / totalCount; + + if (count > totalCount) { + percent = 100; + } + } + + let options: any[] = []; + + if (method === METHODS.EMAIL) { + options = AWS_EMAIL_DELIVERY_STATUSES.OPTIONS; + } + if (method === METHODS.SMS) { + options = SMS_DELIVERY_STATUSES.OPTIONS; + } + + const option = options.find(opt => opt.value === kind); + let icon = 'cube-2'; + let label = 'Total'; + let description = 'Total count'; + + if (option) { + icon = option.icon; + label = option.label; + description = option.description; + } + + return ( + + + + + + + +
+ {label} + {description} +
+ + {s.numberFormat(count)} {percent ? ({percent}%) : null} + +
+
+ ); +} diff --git a/ui/src/modules/engage/components/EngageStats.tsx b/ui/src/modules/engage/components/EngageStats.tsx index de597ebed3f..c2efc328ef8 100644 --- a/ui/src/modules/engage/components/EngageStats.tsx +++ b/ui/src/modules/engage/components/EngageStats.tsx @@ -1,5 +1,4 @@ import Attachment from 'modules/common/components/Attachment'; -import Icon from 'modules/common/components/Icon'; import { __ } from 'modules/common/utils'; import Wrapper from 'modules/layout/components/Wrapper'; import { @@ -7,42 +6,46 @@ import { Subject } from 'modules/settings/integrations/components/mail/styles'; import React from 'react'; -import { METHODS, SMS_DELIVERY_STATUSES } from '../constants'; import { - Box, - BoxContent, - BoxHeader, + AWS_EMAIL_DELIVERY_STATUSES, + METHODS, + SMS_DELIVERY_STATUSES +} from '../constants'; +import { FlexContainer, Half, - IconContainer, PreviewContent, RightSection, Shell, Title } from '../styles'; import { IEngageMessage, IEngageSmsStats, IEngageStats } from '../types'; +import StatItem from './EngageStatItem'; type Props = { message: IEngageMessage; }; class EmailStatistics extends React.Component { - renderBox(icon, name, type) { + renderBox(method: string, count: number, totalCount?: number, kind?: string) { return ( - - - - - - - -
{name}
- {type || 0} -
-
+ ); } + renderEmailBox(count: number, totalCount?: number, kind?: string) { + return this.renderBox(METHODS.EMAIL, count, totalCount, kind); + } + + renderSmsBox(count: number, totalCount?: number, kind?: string) { + return this.renderBox(METHODS.SMS, count, totalCount, kind); + } + renderAttachments() { const { email } = this.props.message; @@ -146,19 +149,47 @@ class EmailStatistics extends React.Component { return ( - {this.renderBox('cube-2', 'Total', emailStats.total)} - {this.renderBox('telegram-alt', 'Sent', emailStats.send)} - {this.renderBox('comment-check', 'Delivered', emailStats.delivery)} - {this.renderBox('envelope-open', 'Opened', emailStats.open)} - {this.renderBox('mouse-alt', 'Clicked', emailStats.click)} - {this.renderBox('frown', 'Complaint', emailStats.complaint)} - {this.renderBox('arrows-up-right', 'Bounce', emailStats.bounce)} - {this.renderBox( - 'ban', - 'Rendering failure', - emailStats.renderingfailure + {this.renderEmailBox(emailStats.total)} + {this.renderEmailBox( + emailStats.send, + emailStats.total, + AWS_EMAIL_DELIVERY_STATUSES.SEND + )} + {this.renderEmailBox( + emailStats.delivery, + emailStats.total, + AWS_EMAIL_DELIVERY_STATUSES.DELIVERY + )} + {this.renderEmailBox( + emailStats.open, + emailStats.total, + AWS_EMAIL_DELIVERY_STATUSES.OPEN + )} + {this.renderEmailBox( + emailStats.click, + emailStats.total, + AWS_EMAIL_DELIVERY_STATUSES.CLICK + )} + {this.renderEmailBox( + emailStats.complaint, + emailStats.total, + AWS_EMAIL_DELIVERY_STATUSES.COMPLAINT + )} + {this.renderEmailBox( + emailStats.bounce, + emailStats.total, + AWS_EMAIL_DELIVERY_STATUSES.BOUNCE + )} + {this.renderEmailBox( + emailStats.renderingfailure, + emailStats.total, + AWS_EMAIL_DELIVERY_STATUSES.RENDERING_FAILURE + )} + {this.renderEmailBox( + emailStats.reject, + emailStats.total, + AWS_EMAIL_DELIVERY_STATUSES.REJECT )} - {this.renderBox('times-circle', 'Rejected', emailStats.reject)} ); } @@ -173,33 +204,37 @@ class EmailStatistics extends React.Component { return ( - {this.renderBox('cube-2', 'Total', stats.total)} - {this.renderBox('list-ul', SMS_DELIVERY_STATUSES.QUEUED, stats.queued)} - {this.renderBox( - 'comment-alt-message', - SMS_DELIVERY_STATUSES.SENDING, - stats.sending + {this.renderSmsBox(stats.total)} + {this.renderSmsBox( + stats.queued, + stats.total, + SMS_DELIVERY_STATUSES.QUEUED + )} + {this.renderSmsBox( + stats.sending, + stats.total, + SMS_DELIVERY_STATUSES.SENDING )} - {this.renderBox('send', SMS_DELIVERY_STATUSES.SENT, stats.sent)} - {this.renderBox( - 'checked', - SMS_DELIVERY_STATUSES.DELIVERED, - stats.delivered + {this.renderSmsBox(stats.sent, stats.total, SMS_DELIVERY_STATUSES.SENT)} + {this.renderSmsBox( + stats.delivered, + stats.total, + SMS_DELIVERY_STATUSES.DELIVERED )} - {this.renderBox( - 'comment-alt-block', - SMS_DELIVERY_STATUSES.SENDING_FAILED, - stats.sending_failed + {this.renderSmsBox( + stats.sending_failed, + stats.total, + SMS_DELIVERY_STATUSES.SENDING_FAILED )} - {this.renderBox( - 'multiply', - SMS_DELIVERY_STATUSES.DELIVERY_FAILED, - stats.delivery_failed + {this.renderSmsBox( + stats.delivery_failed, + stats.total, + SMS_DELIVERY_STATUSES.DELIVERY_FAILED )} - {this.renderBox( - 'comment-alt-question', - SMS_DELIVERY_STATUSES.DELIVERY_UNCONFIRMED, - stats.delivery_unconfirmed + {this.renderSmsBox( + stats.delivery_unconfirmed, + stats.total, + SMS_DELIVERY_STATUSES.DELIVERY_UNCONFIRMED )} ); diff --git a/ui/src/modules/engage/components/MessageList.tsx b/ui/src/modules/engage/components/MessageList.tsx index 066cb6a7e10..cc5fdfd4969 100755 --- a/ui/src/modules/engage/components/MessageList.tsx +++ b/ui/src/modules/engage/components/MessageList.tsx @@ -5,6 +5,7 @@ import FormControl from 'modules/common/components/form/Control'; import ModalTrigger from 'modules/common/components/ModalTrigger'; import Pagination from 'modules/common/components/pagination/Pagination'; import Table from 'modules/common/components/table'; +import colors from 'modules/common/styles/colors'; import { __ } from 'modules/common/utils'; import Wrapper from 'modules/layout/components/Wrapper'; import { EMPTY_CONTENT_ENGAGE } from 'modules/settings/constants'; @@ -15,6 +16,7 @@ import MessageListRow from '../containers/MessageListRow'; import Sidebar from '../containers/Sidebar'; import { ChooseBox, FlexContainer } from '../styles'; import { IEngageMessage } from '../types'; +import PercentItem, { ItemWrapper } from './PercentItem'; type Props = { messages: IEngageMessage[]; @@ -26,6 +28,7 @@ type Props = { toggleAll: (targets: IEngageMessage[], name: string) => void; loading: boolean; queryParams: any; + emailPercentages: any; }; class List extends React.Component { @@ -69,6 +72,98 @@ class List extends React.Component { ); } + renderPercentage() { + const { emailPercentages } = this.props; + + if (!emailPercentages) { + return null; + } + + const trigger = ( + + ); + + const { + avgBouncePercent, + avgComplaintPercent, + avgDeliveryPercent, + avgOpenPercent, + avgClickPercent, + avgRenderingFailurePercent, + avgRejectPercent, + avgSendPercent + } = emailPercentages; + + const content = () => ( + +
Average email statistics:
+ + + + + + + + + + +
+ ); + + return ( + + ); + } + renderRightActionBar = () => { const trigger = (