diff --git a/apps/meteor/app/api/server/v1/email-inbox.ts b/apps/meteor/app/api/server/v1/email-inbox.ts
index e94370acf3f3..293ae2e6b8aa 100644
--- a/apps/meteor/app/api/server/v1/email-inbox.ts
+++ b/apps/meteor/app/api/server/v1/email-inbox.ts
@@ -29,6 +29,7 @@ API.v1.addRoute(
if (!hasPermission(this.userId, 'manage-email-inbox')) {
throw new Error('error-not-allowed');
}
+
check(this.bodyParams, {
_id: Match.Maybe(String),
active: Boolean,
@@ -50,6 +51,7 @@ API.v1.addRoute(
username: String,
password: String,
secure: Boolean,
+ maxRetries: Number,
}),
});
@@ -126,6 +128,7 @@ API.v1.addRoute(
const { email } = this.queryParams;
// TODO: Chapter day backend - check if user has permission to view this email inbox instead of null values
+ // TODO: Chapter day: Remove this endpoint and move search to GET /email-inbox
const emailInbox = await EmailInbox.findOne({ email });
return API.v1.success({ emailInbox });
diff --git a/apps/meteor/app/models/server/models/LivechatRooms.js b/apps/meteor/app/models/server/models/LivechatRooms.js
index 6d17be960dbf..b0ebd2369e05 100644
--- a/apps/meteor/app/models/server/models/LivechatRooms.js
+++ b/apps/meteor/app/models/server/models/LivechatRooms.js
@@ -198,7 +198,7 @@ export class LivechatRooms extends Base {
const query = {
't': 'l',
'v.token': visitorToken,
- 'email.thread': emailThread,
+ '$or': [{ 'email.thread': { $elemMatch: { $in: emailThread } } }, { 'email.thread': new RegExp(emailThread.join('|')) }],
};
return this.findOne(query, options);
@@ -208,7 +208,7 @@ export class LivechatRooms extends Base {
const query = {
't': 'l',
'v.token': visitorToken,
- 'email.thread': emailThread,
+ '$or': [{ 'email.thread': { $elemMatch: { $in: emailThread } } }, { 'email.thread': new RegExp(emailThread.join('|')) }],
...(departmentId && { departmentId }),
};
@@ -220,12 +220,22 @@ export class LivechatRooms extends Base {
't': 'l',
'open': true,
'v.token': visitorToken,
- 'email.thread': emailThread,
+ '$or': [{ 'email.thread': { $elemMatch: { $in: emailThread } } }, { 'email.thread': new RegExp(emailThread.join('|')) }],
};
return this.findOne(query, options);
}
+ updateEmailThreadByRoomId(roomId, threadIds) {
+ const query = {
+ $addToSet: {
+ 'email.thread': threadIds,
+ },
+ };
+
+ return this.update({ _id: roomId }, query);
+ }
+
findOneLastServedAndClosedByVisitorToken(visitorToken, options = {}) {
const query = {
't': 'l',
diff --git a/apps/meteor/client/views/admin/emailInbox/EmailInboxForm.js b/apps/meteor/client/views/admin/emailInbox/EmailInboxForm.js
index 47cf1d63df4c..582e3c7de124 100644
--- a/apps/meteor/client/views/admin/emailInbox/EmailInboxForm.js
+++ b/apps/meteor/client/views/admin/emailInbox/EmailInboxForm.js
@@ -40,6 +40,7 @@ const initialValues = {
imapUsername: '',
imapPassword: '',
imapSecure: false,
+ imapRetries: 10,
};
const getInitialValues = (data) => {
@@ -68,6 +69,7 @@ const getInitialValues = (data) => {
imapUsername: imap.username ?? '',
imapPassword: imap.password ?? '',
imapSecure: imap.secure ?? false,
+ imapRetries: imap.maxRetries ?? 10,
};
};
@@ -96,6 +98,7 @@ function EmailInboxForm({ id, data }) {
handleImapPort,
handleImapUsername,
handleImapPassword,
+ handleImapRetries,
handleImapSecure,
} = handlers;
const {
@@ -116,6 +119,7 @@ function EmailInboxForm({ id, data }) {
imapPort,
imapUsername,
imapPassword,
+ imapRetries,
imapSecure,
} = values;
@@ -174,15 +178,16 @@ function EmailInboxForm({ id, data }) {
username: imapUsername,
password: imapPassword,
secure: imapSecure,
+ maxRetries: parseInt(imapRetries),
};
- const departmentValue = department.value;
+
const payload = {
active,
name,
email,
description,
senderInfo,
- department: departmentValue,
+ department: typeof department === 'string' ? department : department.value,
smtp,
imap,
};
@@ -331,6 +336,12 @@ function EmailInboxForm({ id, data }) {
+
+ {t('Max_Retry')}*
+
+
+
+
{t('Connect_SSL_TLS')}
diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
index 1b4783f86748..65428c1e721f 100644
--- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
+++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
@@ -3090,6 +3090,7 @@
"Max_number_of_chats_per_agent": "Max. number of simultaneous chats",
"Max_number_of_chats_per_agent_description": "The max. number of simultaneous chats that the agents can attend",
"Max_number_of_uses": "Max number of uses",
+ "Max_Retry": "Maximum attemps to reconnect to the server",
"Maximum": "Maximum",
"Maximum_number_of_guests_reached": "Maximum number of guests reached",
"Me": "Me",
diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json
index 42b3bd73cc1a..31cbda9faa0d 100644
--- a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json
+++ b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json
@@ -2869,6 +2869,7 @@
"Max_number_of_chats_per_agent": "Número máximo de conversas simultâneas",
"Max_number_of_chats_per_agent_description": "Número máximo de conversas simultâneas de que um agente pode participar",
"Max_number_of_uses": "Número máximo de usos",
+ "Max_Retry": "Número máximo de tentativas de conexão com o servidor",
"Maximum": "Máximo",
"Maximum_number_of_guests_reached": "Número máximo de visitantes atingido",
"Me": "Eu",
diff --git a/apps/meteor/server/email/IMAPInterceptor.ts b/apps/meteor/server/email/IMAPInterceptor.ts
index 5806ab79de87..00646f3ba844 100644
--- a/apps/meteor/server/email/IMAPInterceptor.ts
+++ b/apps/meteor/server/email/IMAPInterceptor.ts
@@ -5,11 +5,14 @@ import type Connection from 'imap';
import type { ParsedMail } from 'mailparser';
import { simpleParser } from 'mailparser';
+import { logger } from '../features/EmailInbox/logger';
+
type IMAPOptions = {
deleteAfterRead: boolean;
filter: any[];
rejectBeforeTS?: Date;
markSeen: boolean;
+ maxRetries: number;
};
export declare interface IMAPInterceptor {
@@ -19,30 +22,42 @@ export declare interface IMAPInterceptor {
export class IMAPInterceptor extends EventEmitter {
private imap: IMAP;
- private options: IMAPOptions;
+ private config: IMAP.Config;
+
+ private initialBackoffDurationMS = 30000;
+
+ private backoff: NodeJS.Timeout;
- constructor(imapConfig: IMAP.Config, options?: Partial) {
+ private retries = 0;
+
+ constructor(
+ imapConfig: IMAP.Config,
+ private options: IMAPOptions = {
+ deleteAfterRead: false,
+ filter: ['UNSEEN'],
+ markSeen: true,
+ maxRetries: 10,
+ },
+ ) {
super();
+ this.config = imapConfig;
+
this.imap = new IMAP({
- connTimeout: 30000,
+ connTimeout: 10000,
keepalive: true,
...(imapConfig.tls && { tlsOptions: { servername: imapConfig.host } }),
...imapConfig,
});
- this.options = {
- deleteAfterRead: false,
- filter: ['UNSEEN'],
- markSeen: true,
- ...options,
- };
-
// On successfully connected.
this.imap.on('ready', () => {
if (this.imap.state !== 'disconnected') {
+ clearTimeout(this.backoff);
+ this.retries = 0;
this.openInbox((err) => {
if (err) {
+ logger.error(`Error occurred during imap on inbox ${this.config.user}: `, err);
throw err;
}
// fetch new emails & wait [IDLE]
@@ -54,19 +69,20 @@ export class IMAPInterceptor extends EventEmitter {
});
});
} else {
- this.log('IMAP did not connected.');
+ logger.error(`IMAP did not connect on inbox ${this.config.user}`);
this.imap.end();
+ this.reconnect();
}
});
this.imap.on('error', (err: Error) => {
- this.log('Error occurred ...', err);
- throw err;
+ logger.error(`Error occurred on inbox ${this.config.user}: `, err);
+ this.stop(() => this.reconnect());
});
- }
- log(...msg: any[]): void {
- console.log(...msg);
+ this.imap.on('close', () => {
+ this.reconnect();
+ });
}
openInbox(cb: (error: Error, mailbox: Connection.Box) => void): void {
@@ -86,30 +102,37 @@ export class IMAPInterceptor extends EventEmitter {
}
stop(callback = new Function()): void {
- this.log('IMAP stop called');
+ logger.info('IMAP stop called');
this.imap.end();
this.imap.once('end', () => {
- this.log('IMAP stopped');
+ logger.info('IMAP stopped');
callback?.();
});
- callback?.();
}
- restart(): void {
- this.stop(() => {
- this.log('Restarting IMAP ....');
+ reconnect(): void {
+ const loop = (): void => {
this.start();
- });
+ if (this.retries < this.options.maxRetries) {
+ this.retries += 1;
+ this.initialBackoffDurationMS *= 2;
+ this.backoff = setTimeout(loop, this.initialBackoffDurationMS);
+ } else {
+ logger.error(`IMAP reconnection failed on inbox ${this.config.user}`);
+ clearTimeout(this.backoff);
+ }
+ };
+ this.backoff = setTimeout(loop, this.initialBackoffDurationMS);
}
// Fetch all UNSEEN messages and pass them for further processing
getEmails(): void {
this.imap.search(this.options.filter, (err, newEmails) => {
+ logger.debug(`IMAP search on inbox ${this.config.user} returned ${newEmails.length} new emails: `, newEmails);
if (err) {
- this.log(err);
+ logger.error(err);
throw err;
}
-
// newEmails => array containing serials of unseen messages
if (newEmails.length > 0) {
const fetch = this.imap.fetch(newEmails, {
@@ -119,6 +142,8 @@ export class IMAPInterceptor extends EventEmitter {
});
fetch.on('message', (msg, seqno) => {
+ logger.debug('E-mail received', seqno, msg);
+
msg.on('body', (stream, type) => {
if (type.which !== '') {
return;
@@ -126,10 +151,9 @@ export class IMAPInterceptor extends EventEmitter {
simpleParser(stream, (_err, email) => {
if (this.options.rejectBeforeTS && email.date && email.date < this.options.rejectBeforeTS) {
- this.log('Rejecting email', email.subject);
+ logger.error(`Rejecting email on inbox ${this.config.user}`, email.subject);
return;
}
-
this.emit('email', email);
});
});
@@ -140,7 +164,7 @@ export class IMAPInterceptor extends EventEmitter {
if (this.options.deleteAfterRead) {
this.imap.seq.addFlags(seqno, 'Deleted', (err) => {
if (err) {
- this.log(`Mark deleted error: ${err}`);
+ logger.error(`Mark deleted error: ${err}`);
}
});
}
@@ -148,7 +172,7 @@ export class IMAPInterceptor extends EventEmitter {
});
fetch.once('error', (err) => {
- this.log(`Fetch error: ${err}`);
+ logger.error(`Fetch error: ${err}`);
});
}
});
diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox.ts b/apps/meteor/server/features/EmailInbox/EmailInbox.ts
index 12e122a56ba2..739888aa29dc 100644
--- a/apps/meteor/server/features/EmailInbox/EmailInbox.ts
+++ b/apps/meteor/server/features/EmailInbox/EmailInbox.ts
@@ -38,17 +38,21 @@ export async function configureEmailInboxes(): Promise {
user: emailInboxRecord.imap.username,
host: emailInboxRecord.imap.server,
port: emailInboxRecord.imap.port,
- tls: emailInboxRecord.imap.secure,
- tlsOptions: {
- rejectUnauthorized: false,
- },
- // debug: (...args: any[]): void => logger.debug(args),
+ ...(emailInboxRecord.imap.secure
+ ? {
+ tls: emailInboxRecord.imap.secure,
+ tlsOptions: {
+ rejectUnauthorized: false,
+ },
+ }
+ : {}),
},
{
deleteAfterRead: false,
filter: [['UNSEEN'], ['SINCE', emailInboxRecord._updatedAt]],
rejectBeforeTS: emailInboxRecord._updatedAt,
markSeen: true,
+ maxRetries: emailInboxRecord.imap.maxRetries,
},
);
diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts
index f2bad29a632b..4105b301b45d 100644
--- a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts
+++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts
@@ -2,7 +2,7 @@ import stripHtml from 'string-strip-html';
import { Random } from 'meteor/random';
import type { ParsedMail, Attachment } from 'mailparser';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
-import type { ILivechatVisitor } from '@rocket.chat/core-typings';
+import type { ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { OmnichannelSourceType } from '@rocket.chat/core-typings';
import { LivechatVisitors } from '@rocket.chat/models';
@@ -127,14 +127,16 @@ async function uploadAttachment(attachment: Attachment, rid: string, visitorToke
}
export async function onEmailReceived(email: ParsedMail, inbox: string, department = ''): Promise {
- logger.debug(`New email conversation received on inbox ${inbox}. Will be assigned to department ${department}`);
+ logger.debug(`New email conversation received on inbox ${inbox}. Will be assigned to department ${department}`, email);
if (!email.from?.value?.[0]?.address) {
return;
}
const references = typeof email.references === 'string' ? [email.references] : email.references;
+ const initialRef = [email.messageId, email.inReplyTo].filter(Boolean) as string[];
+ const thread = (references?.length ? references : []).flatMap((t: string) => t.split(',')).concat(initialRef);
- const thread = references?.[0] ?? email.messageId;
+ logger.debug(`Received new email conversation with thread ${thread} on inbox ${inbox} from ${email.from.value[0].address}`);
logger.debug(`Fetching guest for visitor ${email.from.value[0].address}`);
const guest = await getGuestByEmail(email.from.value[0].address, email.from.value[0].name, department);
@@ -146,7 +148,7 @@ export async function onEmailReceived(email: ParsedMail, inbox: string, departme
logger.debug(`Guest ${guest._id} obtained. Attempting to find or create a room on department ${department}`);
- let room = LivechatRooms.findOneByVisitorTokenAndEmailThreadAndDepartment(guest.token, thread, department, {});
+ let room: IOmnichannelRoom = LivechatRooms.findOneByVisitorTokenAndEmailThreadAndDepartment(guest.token, thread, department, {});
logger.debug({
msg: 'Room found for guest',
@@ -170,6 +172,7 @@ export async function onEmailReceived(email: ParsedMail, inbox: string, departme
const msgId = Random.id();
logger.debug(`Sending email message to room ${rid} for visitor ${guest._id}. Conversation assigned to department ${department}`);
+
Livechat.sendMessage({
guest,
message: {
@@ -211,7 +214,7 @@ export async function onEmailReceived(email: ParsedMail, inbox: string, departme
],
rid,
email: {
- references,
+ thread,
messageId: email.messageId,
},
},
@@ -258,6 +261,7 @@ export async function onEmailReceived(email: ParsedMail, inbox: string, departme
},
},
);
+ LivechatRooms.updateEmailThreadByRoomId(room._id, thread);
})
.catch((err) => {
Livechat.logger.error({
diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts
index 97a2e711531d..67088c5a2f31 100644
--- a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts
+++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts
@@ -7,11 +7,12 @@ import { Uploads } from '@rocket.chat/models';
import { callbacks } from '../../../lib/callbacks';
import { FileUpload } from '../../../app/file-upload/server';
import { slashCommands } from '../../../app/utils/server';
-import { Messages, Rooms, Users } from '../../../app/models/server';
+import { Messages, Rooms, Users, LivechatRooms } from '../../../app/models/server';
import type { Inbox } from './EmailInbox';
import { inboxes } from './EmailInbox';
import { sendMessage } from '../../../app/lib/server/functions/sendMessage';
import { settings } from '../../../app/settings/server';
+import { logger } from './logger';
const livechatQuoteRegExp = /^\[\s\]\(https?:\/\/.+\/live\/.+\?msg=(?.+?)\)\s(?.+)/s;
@@ -36,8 +37,8 @@ const sendErrorReplyMessage = (error: string, options: any): void => {
sendMessage(user, message, { _id: options.rid });
};
-function sendEmail(inbox: Inbox, mail: Mail.Options, options?: any): void {
- inbox.smtp
+async function sendEmail(inbox: Inbox, mail: Mail.Options, options?: any): Promise<{ messageId: string }> {
+ return inbox.smtp
.sendMail({
from: inbox.config.senderInfo
? {
@@ -48,10 +49,11 @@ function sendEmail(inbox: Inbox, mail: Mail.Options, options?: any): void {
...mail,
})
.then((info) => {
- console.log('Message sent: %s', info.messageId);
+ logger.info('Message sent: %s', info.messageId);
+ return info;
})
.catch((error) => {
- console.log('Error sending Email reply: %s', error.message);
+ logger.error('Error sending Email reply: %s', error.message);
if (!options?.msgId) {
return;
@@ -116,7 +118,7 @@ slashCommands.add({
sender: message.u.username,
rid: message.rid,
},
- );
+ ).then((info) => LivechatRooms.updateEmailThreadByRoomId(room._id, info.messageId));
});
Messages.update(
@@ -216,7 +218,7 @@ callbacks.add(
sender: message.u.username,
rid: room._id,
},
- );
+ ).then((info) => LivechatRooms.updateEmailThreadByRoomId(room._id, info.messageId));
message.msg = match.groups.text;
@@ -269,7 +271,7 @@ export async function sendTestEmailToInbox(emailInboxRecord: IEmailInbox, user:
throw new Error('user-without-verified-email');
}
- console.log(`Sending testing email to ${address}`);
+ logger.info(`Sending testing email to ${address}`);
sendEmail(inbox, {
to: address,
subject: 'Test of inbox configuration',
diff --git a/apps/meteor/tests/end-to-end/api/livechat/11-email-inbox.ts b/apps/meteor/tests/end-to-end/api/livechat/11-email-inbox.ts
new file mode 100644
index 000000000000..2cb4e6e3db49
--- /dev/null
+++ b/apps/meteor/tests/end-to-end/api/livechat/11-email-inbox.ts
@@ -0,0 +1,93 @@
+import type { IEmailInbox } from '@rocket.chat/core-typings';
+import { expect } from 'chai';
+import type { Response } from 'supertest';
+
+import { getCredentials, api, request, credentials } from '../../../data/api-data';
+import { createDepartment } from '../../../data/livechat/rooms';
+
+// TODO: Add tests with actual e-mail servers involved
+
+describe('Email inbox', () => {
+ before((done) => getCredentials(done));
+ let testInbox = '';
+ before((done) => {
+ createDepartment()
+ .then((dept) =>
+ request
+ .post(api('email-inbox'))
+ .set(credentials)
+ .send({
+ active: true,
+ name: 'test-email-inbox##',
+ email: 'test-email@example.com',
+ description: 'test email inbox',
+ senderInfo: 'test email inbox',
+ department: dept.name,
+ smtp: {
+ server: 'smtp.example.com',
+ port: 587,
+ username: 'example@example.com',
+ password: 'not-a-real-password',
+ secure: true,
+ },
+ imap: {
+ server: 'imap.example.com',
+ port: 993,
+ username: 'example@example.com',
+ password: 'not-a-real-password',
+ secure: true,
+ maxRetries: 10,
+ },
+ })
+ .expect('Content-Type', 'application/json')
+ .expect(200)
+ .expect((res: Response) => {
+ expect(res.body).to.have.property('success');
+ if (res.body.success === true) {
+ testInbox = res.body._id;
+ } else {
+ expect(res.body).to.have.property('error');
+ expect(res.body.error.includes('E11000')).to.be.eq(true);
+ }
+ }),
+ )
+ .finally(done);
+ });
+ after((done) => {
+ if (testInbox) {
+ request
+ .delete(api(`email-inbox/${testInbox}`))
+ .set(credentials)
+ .send()
+ .expect(200)
+ .end(() => done());
+ return;
+ }
+ done();
+ });
+ describe('GET email-inbox.list', () => {
+ it('should return a list of email inboxes', (done) => {
+ request
+ .get(api('email-inbox.list'))
+ .set(credentials)
+ .send()
+ .expect('Content-Type', 'application/json')
+ .expect(200)
+ .expect((res: Response) => {
+ expect(res.body).to.have.property('emailInboxes');
+ expect(res.body.emailInboxes).to.be.an('array');
+ expect(res.body.emailInboxes).to.have.length.of.at.least(1);
+ expect(res.body.emailInboxes.filter((ibx: IEmailInbox) => ibx.email === 'test-email@example.com')).to.have.length.gte(1);
+ // make sure we delete the test inbox, even if creation failed on this test run
+ testInbox = res.body.emailInboxes.filter((ibx: IEmailInbox) => ibx.email === 'test-email@example.com')[0]._id;
+ expect(res.body).to.have.property('total');
+ expect(res.body.total).to.be.a('number');
+ expect(res.body).to.have.property('count');
+ expect(res.body.count).to.be.a('number');
+ expect(res.body).to.have.property('offset');
+ expect(res.body.offset).to.be.a('number');
+ })
+ .end(() => done());
+ });
+ });
+});
diff --git a/packages/core-typings/src/IEmailInbox.ts b/packages/core-typings/src/IEmailInbox.ts
index 2f0ff390297a..5dbd7394ead7 100644
--- a/packages/core-typings/src/IEmailInbox.ts
+++ b/packages/core-typings/src/IEmailInbox.ts
@@ -19,6 +19,7 @@ export interface IEmailInbox {
username: string;
password: string;
secure: boolean;
+ maxRetries: number;
};
_createdAt: Date;
_createdBy: {
diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts
index 1c025318ff21..f80b5c95cb77 100644
--- a/packages/core-typings/src/IRoom.ts
+++ b/packages/core-typings/src/IRoom.ts
@@ -138,7 +138,7 @@ export interface IOmnichannelGenericRoom extends Omit