diff --git a/backend/plugins/frontline_api/package.json b/backend/plugins/frontline_api/package.json index e285190714..e042ab90a9 100644 --- a/backend/plugins/frontline_api/package.json +++ b/backend/plugins/frontline_api/package.json @@ -17,6 +17,7 @@ "erxes-api-shared": "workspace:^", "fbgraph": "^1.4.4", "moment-timezone": "^0.5.48", + "nodemailer": "^7.0.3", "strip": "^3.0.0" } } diff --git a/backend/plugins/frontline_api/src/apollo/resolvers/mutations.ts b/backend/plugins/frontline_api/src/apollo/resolvers/mutations.ts index 4ae959c571..c226dd595a 100644 --- a/backend/plugins/frontline_api/src/apollo/resolvers/mutations.ts +++ b/backend/plugins/frontline_api/src/apollo/resolvers/mutations.ts @@ -2,10 +2,11 @@ import { channelMutations } from '@/inbox/graphql/resolvers/mutations/channels'; import { conversationMutations } from '@/inbox/graphql/resolvers/mutations/conversations'; import { integrationMutations } from '@/inbox/graphql/resolvers/mutations/integrations'; import { facebookMutations } from '@/integrations/facebook/graphql/resolvers/mutations'; - +import { imapMutations } from '@/integrations/imap/graphql/resolvers/mutations'; export const mutations = { ...channelMutations, ...conversationMutations, ...integrationMutations, - ...facebookMutations + ...facebookMutations, + ...imapMutations }; diff --git a/backend/plugins/frontline_api/src/apollo/resolvers/queries.ts b/backend/plugins/frontline_api/src/apollo/resolvers/queries.ts index cf82b7e3ee..67406a6431 100644 --- a/backend/plugins/frontline_api/src/apollo/resolvers/queries.ts +++ b/backend/plugins/frontline_api/src/apollo/resolvers/queries.ts @@ -2,10 +2,12 @@ import { channelQueries } from '@/inbox/graphql/resolvers/queries/channels'; import { conversationQueries } from '@/inbox/graphql/resolvers/queries/conversations'; import { integrationQueries } from '@/inbox/graphql/resolvers/queries/integrations'; import { facebookQueries } from '@/integrations/facebook/graphql/resolvers/queries'; +import { imapQueries } from '@/integrations/imap/graphql/resolvers/queries'; export const queries = { ...channelQueries, ...conversationQueries, ...integrationQueries, - ...facebookQueries + ...facebookQueries, + ...imapQueries }; diff --git a/backend/plugins/frontline_api/src/apollo/schema/schema.ts b/backend/plugins/frontline_api/src/apollo/schema/schema.ts index 433b3d4797..0bac052af3 100644 --- a/backend/plugins/frontline_api/src/apollo/schema/schema.ts +++ b/backend/plugins/frontline_api/src/apollo/schema/schema.ts @@ -23,18 +23,27 @@ import { types as FacebookTypes, } from '@/integrations/facebook/graphql/schema/facebook'; + +import { + mutations as IMapMutations, + queries as IMapQueries, + types as IMapTypes, +} from '@/integrations/imap/graphql/schemas'; + export const types = ` ${TypeExtensions} ${ChannelsTypes} ${ConversationsTypes} ${IntegrationsTypes} ${FacebookTypes} + ${IMapTypes} `; export const queries = ` ${ChannelsQueries} ${ConversationsQueries} ${IntegrationsQueries} ${FacebookQueries} + ${IMapQueries} `; export const mutations = ` @@ -42,5 +51,6 @@ export const mutations = ` ${ConversationsMutations} ${IntegrationsMutations} ${FacebookMutations} + ${IMapMutations} `; export default { types, queries, mutations }; diff --git a/backend/plugins/frontline_api/src/connectionResolvers.ts b/backend/plugins/frontline_api/src/connectionResolvers.ts index f13ba6b9ba..510a89b6d9 100644 --- a/backend/plugins/frontline_api/src/connectionResolvers.ts +++ b/backend/plugins/frontline_api/src/connectionResolvers.ts @@ -6,6 +6,9 @@ import { IIntegrationDocument } from '@/inbox/@types/integrations'; import { IConversationDocument } from '@/inbox/@types/conversations'; import { IMessageDocument } from '@/inbox/@types/conversationMessages'; import { IFacebookIntegrationDocument } from '@/integrations/facebook/@types/integrations'; +import { IIMapIntegrationDocument } from '@/integrations/imap/@types/integrations'; +import { IIMapCustomerDocument } from '@/integrations/imap/@types/customers'; +import { IIMapMessageDocument } from '@/integrations/imap/@types/messages'; import { IFacebookLogDocument } from '@/integrations/facebook/@types/logs'; import { IFacebookAccountDocument } from '@/integrations/facebook/@types/accounts'; import { IFacebookCustomerDocument } from '@/integrations/facebook/@types/customers'; @@ -68,7 +71,18 @@ import { IFacebookConfigModel, loadFacebookConfigClass, } from '@/integrations/facebook/db/models/Config'; - +import { + IIMapCustomerModel, + loadImapCustomerClass, +} from '@/integrations/imap/db/models/Customers'; +import { + IIMapIntegrationModel, + loadImapIntegrationClass, +} from '@/integrations/imap/db/models/Integrations'; +import { + IIMapMessageModel, + loadImapMessageClass, +} from '@/integrations/imap/db/models/Messages'; export interface IModels { Channels: IChannelModel; Integrations: IIntegrationModel; @@ -84,6 +98,9 @@ export interface IModels { FacebookLogs: IFacebookLogModel; FacebookPostConversations: IFacebookPostConversationModel; FacebookConfigs: IFacebookConfigModel; + ImapCustomers: IIMapCustomerModel; + ImapIntegrations: IIMapIntegrationModel; + ImapMessages: IIMapMessageModel; } export interface IContext extends IMainContext { @@ -163,6 +180,20 @@ export const loadClasses = ( IFacebookConfigDocument, IFacebookConfigModel >('facebook_configs', loadFacebookConfigClass(models)); + + models.ImapCustomers = db.model( + 'imap_customers', + loadImapCustomerClass(models), + ); + models.ImapIntegrations = db.model( + 'imap_integrations', + loadImapIntegrationClass(models), + ); + + models.ImapMessages = db.model( + 'imap_messages', + loadImapMessageClass(models), + ); return models; }; diff --git a/backend/plugins/frontline_api/src/modules/integrations/imap/@types/customers.ts b/backend/plugins/frontline_api/src/modules/integrations/imap/@types/customers.ts new file mode 100644 index 0000000000..3e8143aff1 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/integrations/imap/@types/customers.ts @@ -0,0 +1,11 @@ +import { Document } from 'mongoose'; +export interface IIMapCustomer { + inboxIntegrationId: string; + contactsId: string; + email: string; + firstName?: string; + lastName?: string; + integrationId?: string; +} + +export interface IIMapCustomerDocument extends IIMapCustomer, Document {} diff --git a/backend/plugins/frontline_api/src/modules/integrations/imap/@types/integrations.ts b/backend/plugins/frontline_api/src/modules/integrations/imap/@types/integrations.ts new file mode 100644 index 0000000000..415de1a083 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/integrations/imap/@types/integrations.ts @@ -0,0 +1,15 @@ +import { Document } from 'mongoose'; +export interface IIMapIntegration { + inboxId: string; + host: string; + smtpHost: string; + smtpPort: string; + mainUser: string; + user: string; + password: string; + healthStatus?: string; + error?: string; + lastFetchDate?: Date; +} + +export interface IIMapIntegrationDocument extends IIMapIntegration, Document {} \ No newline at end of file diff --git a/backend/plugins/frontline_api/src/modules/integrations/imap/@types/messages.ts b/backend/plugins/frontline_api/src/modules/integrations/imap/@types/messages.ts new file mode 100644 index 0000000000..fc100a1be3 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/integrations/imap/@types/messages.ts @@ -0,0 +1,47 @@ +import { Document } from 'mongoose'; + +interface IIMapMail { + name: string; + address: string; +} +export interface IIMapAttachmentParams { + filename: string; + size: number; + mimeType: string; + data?: string; + attachmentId?: string; +} +export interface IIMapMessage { + inboxIntegrationId: string; + inboxConversationId: string; + messageId: string; + subject: string; + body: string; + to: IIMapMail[]; + cc: IIMapMail[]; + bcc: IIMapMail[]; + from: IIMapMail[]; + attachments?: IIMapAttachmentParams[]; + createdAt: Date; +} + +export interface ImapSendMailInput { + to: string | string[]; + subject: string; + body:string; + text: string; + html?: string; + from?: string; + conversationId?: string; + integrationId?: string; + customerId?: string; + attachments?: Array<{name: string; url: string; type?: string; size?: number}>; + replyToMessageId?: string; + shouldOpen?: boolean; + shouldResolve?: boolean; + cc?: string[]; + bcc?: string[]; + replyTo?: string; +} + +export interface IIMapMessageDocument extends IIMapMessage, Document {} \ No newline at end of file diff --git a/backend/plugins/frontline_api/src/modules/integrations/imap/db/definitions/customers.ts b/backend/plugins/frontline_api/src/modules/integrations/imap/db/definitions/customers.ts new file mode 100644 index 0000000000..9ae39d0d1b --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/integrations/imap/db/definitions/customers.ts @@ -0,0 +1,9 @@ + +import { Schema } from 'mongoose'; +export const customerSchema = new Schema({ + inboxIntegrationId: { type: String, required: true }, + contactsId:{ type: String}, + email: { type: String, unique: true }, + firstName: { type: String }, + lastName: { type: String }, +}); diff --git a/backend/plugins/frontline_api/src/modules/integrations/imap/db/definitions/integrations.ts b/backend/plugins/frontline_api/src/modules/integrations/imap/db/definitions/integrations.ts new file mode 100644 index 0000000000..d274d0fd7e --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/integrations/imap/db/definitions/integrations.ts @@ -0,0 +1,20 @@ +import { Schema } from 'mongoose'; +export const integrationSchema = new Schema({ + inboxId: { type: String, required: true }, + host: { type: String, required: true }, + smtpHost: { type: String, required: true }, + smtpPort: { + type: String, + required: true, + validate: { + validator: (v: string) => /^\d+$/.test(v) && parseInt(v) > 0 && parseInt(v) <= 65535, + message: 'smtpPort must be a valid port number' + } + }, + mainUser: { type: String, required: true }, + user: { type: String, required: true }, + password: { type: String, required: true }, + healthStatus: String, + error: String, + lastFetchDate: Date, +}); diff --git a/backend/plugins/frontline_api/src/modules/integrations/imap/db/definitions/messages.ts b/backend/plugins/frontline_api/src/modules/integrations/imap/db/definitions/messages.ts new file mode 100644 index 0000000000..a64c23aba7 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/integrations/imap/db/definitions/messages.ts @@ -0,0 +1,45 @@ +import { Schema } from 'mongoose'; +export const attachmentSchema = new Schema({ + filename: { type: String, required: true }, + mimeType: { type: String, required: true }, + type: { type: String, required: true }, + size: { + type: Number, + required: true, + min: 1, + validate: { + validator: Number.isFinite, + message: 'Size must be a valid number' + } + }, + attachmentId: { type: String, required: true } +}, { _id: false }); + +const emailSchema = new Schema( + { + name: { type: String }, + address: { type: String }, + }, + { _id: false } +); + +export const messageSchema = new Schema({ + inboxIntegrationId: { type: String, required: true }, + inboxConversationId: { type: String, required: true }, + subject: { type: String, required: true }, + messageId: { type: String, required: true, unique: true }, + inReplyTo: { type: String }, + references: [String], + body: { type: String, required: true }, + to: { type: [emailSchema], required: true }, + cc: [emailSchema], + bcc: [emailSchema], + from: { type: [emailSchema], required: true }, + attachments: [attachmentSchema], + createdAt: { type: Date, index: true, default: Date.now }, + type: { + type: String, + enum: ['SENT', 'INBOX'], + required: true + } +}); \ No newline at end of file diff --git a/backend/plugins/frontline_api/src/modules/integrations/imap/db/models/Customers.ts b/backend/plugins/frontline_api/src/modules/integrations/imap/db/models/Customers.ts new file mode 100644 index 0000000000..60d19a10b1 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/integrations/imap/db/models/Customers.ts @@ -0,0 +1,13 @@ +import { Model } from 'mongoose'; +import {IIMapCustomerDocument} from '@/integrations/imap/@types/customers'; +import { customerSchema } from '@/integrations/imap/db/definitions/customers' +export type IIMapCustomerModel = Model; + + +export const loadImapCustomerClass = (models) => { + class Customer {} + + customerSchema.loadClass(Customer); + + return customerSchema; +}; \ No newline at end of file diff --git a/backend/plugins/frontline_api/src/modules/integrations/imap/db/models/Integrations.ts b/backend/plugins/frontline_api/src/modules/integrations/imap/db/models/Integrations.ts new file mode 100644 index 0000000000..d1290dbfb0 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/integrations/imap/db/models/Integrations.ts @@ -0,0 +1,12 @@ +import { Model } from 'mongoose'; +import { IIMapIntegrationDocument} from '@/integrations/imap/@types/integrations'; +import { integrationSchema } from '@/integrations/imap/db/definitions/integrations' +export type IIMapIntegrationModel = Model; + +export const loadImapIntegrationClass = (models) => { + class Integration {} + + integrationSchema.loadClass(Integration); + + return integrationSchema; +}; \ No newline at end of file diff --git a/backend/plugins/frontline_api/src/modules/integrations/imap/db/models/Messages.ts b/backend/plugins/frontline_api/src/modules/integrations/imap/db/models/Messages.ts new file mode 100644 index 0000000000..8ae1afa645 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/integrations/imap/db/models/Messages.ts @@ -0,0 +1,176 @@ +import { Model } from 'mongoose'; +import { IModels } from '~/connectionResolvers'; +import {IIMapMessageDocument } from '@/integrations/imap/@types/messages'; +import * as nodemailer from 'nodemailer'; +import { messageSchema} from '@/integrations/imap/db/definitions/messages' +import { sendTRPCMessage } from 'erxes-api-shared/utils'; +import {ImapSendMailInput} from '@/integrations/imap/@types/messages' + +export interface IIMapMessageModel extends Model { + createSendMail( + args: ImapSendMailInput, + subdomain: string, + models: IModels + ): Promise; +} + +export const loadImapMessageClass = (models) => { + class Message { + public static async createSendMail( + args: any, + subdomain: string, + models: IModels + ) { + const { + integrationId, + conversationId, + subject, + body, + from, + customerId, + to, + attachments, + replyToMessageId, + shouldOpen, + shouldResolve + } = args; + + let customer; + + const selector = customerId + ? { _id: customerId } + : { status: { $ne: 'deleted' }, emails: { $in: to } }; + + customer = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'customers', + action: 'findOne', + input: { selector }, + }); + if (!customer) { + const [primaryEmail] = to; + customer = await sendTRPCMessage({ + pluginName: 'core', + method: 'mutation', + module: 'customers', + action: 'createCustomer', + input: { + doc: { + state: 'lead', + primaryEmail + }, + }, + }); + } + + let integration; + + if (from) { + integration = await models.ImapIntegrations.findOne({ + user: from + }); + } + + if (!integration) { + integration = await models.ImapIntegrations.findOne({ + inboxId: integrationId + }); + } + + if (!integration && conversationId) { + // const conversation = await sendInboxMessage({ + // subdomain, + // action: 'conversations.findOne', + // data: { _id: conversationId }, + // isRPC: true + // }); + + // integration = await models.ImapIntegrations.findOne({ + // inboxId: conversation.integrationId + // }); + } + + if (!integration) { + throw new Error('Integration not found'); + } + + if (conversationId) { + if (shouldResolve) { + // await sendInboxMessage({ + // subdomain, + // action: 'conversations.changeStatus', + // data: { id: conversationId, status: 'closed' }, + // isRPC: true + // }); + } + if (shouldOpen) { + // await sendInboxMessage({ + // subdomain, + // action: 'conversations.changeStatus', + // data: { id: conversationId, status: 'new' }, + // isRPC: true + // }); + } + } + + const transporter = nodemailer.createTransport({ + host: integration.smtpHost, + port: integration.smtpPort, + secure: true, + logger: true, + debug: true, + auth: { + user: integration.mainUser || integration.user, + pass: integration.password + } + }); + + const mailData = { + from, + to, + subject: replyToMessageId ? `Re: ${subject}` : subject, + html: body, + inReplyTo: replyToMessageId, + references: replyToMessageId ? [replyToMessageId] : undefined, + attachments: attachments + ? attachments.map((attach) => ({ + filename: attach.name, + path: attach.url + })) + : [] // Default to an empty array if attachments is undefined + }; + + const info = await transporter.sendMail(mailData); + + const messageDoc = await models.ImapMessages.create({ + inboxIntegrationId: integration.inboxId, + inboxConversationId: conversationId, + createdAt: new Date(), + messageId: info.messageId, + inReplyTo: replyToMessageId, + references: mailData.references, + subject: mailData.subject, + body: mailData.html, + to: (mailData.to || []).map((to) => ({ name: to, address: to })), + from: [{ name: mailData.from, address: mailData.from }], + attachments: attachments + ? attachments.map(({ name, type, size }) => ({ + filename: name, + type, + size + })) + : [], + type: 'SENT' + }); + return { + info, + messageDoc, + }; + } + } + + messageSchema.loadClass(Message); + + return messageSchema; +}; \ No newline at end of file diff --git a/backend/plugins/frontline_api/src/modules/integrations/imap/graphql/resolvers/index.ts b/backend/plugins/frontline_api/src/modules/integrations/imap/graphql/resolvers/index.ts new file mode 100644 index 0000000000..784acaa743 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/integrations/imap/graphql/resolvers/index.ts @@ -0,0 +1,4 @@ +import { imapMutations } from './mutations'; +import { imapQueries } from './queries'; + +export { imapMutations, imapQueries }; diff --git a/backend/plugins/frontline_api/src/modules/integrations/imap/graphql/resolvers/mutations.ts b/backend/plugins/frontline_api/src/modules/integrations/imap/graphql/resolvers/mutations.ts new file mode 100644 index 0000000000..627f142c1d --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/integrations/imap/graphql/resolvers/mutations.ts @@ -0,0 +1,31 @@ +import { IContext } from '~/connectionResolvers'; +import {ImapSendMailInput} from '@/integrations/imap/@types/messages' +export const imapMutations = { + /** + * Send mail + */ + async imapSendMail(_root, args: ImapSendMailInput, { subdomain, models, user }: IContext) { + + try { + // Basic validation + if (!args.to || !args.subject || !args.body) { + throw new Error('Missing required fields: to, subject or text'); + } + + // Authorization check + if (!user) { + throw new Error('Unauthorized access'); + } + + return await models.ImapMessages.createSendMail( + args, + subdomain, + models, + ); + + } catch (error) { + console.error('Error sending mail:', error); + throw new Error(`Failed to send mail: ${error.message}`); + } + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/integrations/imap/graphql/resolvers/queries.ts b/backend/plugins/frontline_api/src/modules/integrations/imap/graphql/resolvers/queries.ts new file mode 100644 index 0000000000..0e834439a8 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/integrations/imap/graphql/resolvers/queries.ts @@ -0,0 +1,72 @@ +import { IContext } from '~/connectionResolvers'; + +export const imapQueries = { + async imapConversationDetail( + _root, + { conversationId }, + { models }: IContext + ) { + const messages = await models.ImapMessages.find({ + inboxConversationId: conversationId + }); + + const convertEmails = (emails) => + (emails || []).map((item) => ({ name: item.name, email: item.address })); + + const getNewContentFromMessageBody = (html) => { + const startIndex = html.indexOf('
'); + + if (startIndex !== -1) { + const endIndex = html.indexOf('

', startIndex); + + if (endIndex !== -1) { + return html.substring( + startIndex, + endIndex + '
'.length + ) + } + } + }; + + const getRepliesFromMessageBody = (html) => { + const startIndex = html.indexOf('
'); + + if (startIndex !== -1) { + const endIndex = html.lastIndexOf('
'); + + if (endIndex !== -1) { + return html.substring(startIndex, endIndex); + } + } + }; + + return messages.map((message): { _id: string, mailData: any, createdAt: Date } => { + const msgBody = + message.body === '
false
\n' || !message.body + ? '
\n' + : message.body; + + return { + _id: String(message._id), + mailData: { + messageId: message.messageId, + from: convertEmails(message.from), + to: convertEmails(message.to), + cc: convertEmails(message.cc), + bcc: convertEmails(message.bcc), + subject: message.subject, + body: msgBody, + newContent: getNewContentFromMessageBody(msgBody), + replies: getRepliesFromMessageBody(msgBody), + attachments: message.attachments || [] + }, + createdAt: message.createdAt + }; + }); + }, + + async imapGetIntegrations(_root, _args, { models }: IContext) { + return models.ImapIntegrations.find(); + }, + +}; diff --git a/backend/plugins/frontline_api/src/modules/integrations/imap/graphql/schemas/index.ts b/backend/plugins/frontline_api/src/modules/integrations/imap/graphql/schemas/index.ts new file mode 100644 index 0000000000..5ca369b46a --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/integrations/imap/graphql/schemas/index.ts @@ -0,0 +1,49 @@ + +export const types = ` + type IMap { + _id: String! + title: String + mailData: JSON + createdAt: Date + } + + type IMapIntegration { + inboxId: String + host: String + smtpHost: String + smtpPort: String + mainUser: String + user: String + password: String + } +`; + +export const queries = ` + imapConversationDetail(conversationId: String!): [IMap] + imapGetIntegrations: [IMapIntegration] + imapLogs: [JSON] +`; + + export const mutations = ` + imapSendMail( + integrationId: String + conversationId: String + subject: String! + body: String + to: [String]! + cc: [String] + bcc: [String] + from: String! + shouldResolve: Boolean + shouldOpen: Boolean + headerId: String + replyTo: [String] + inReplyTo: String + threadId: String + messageId: String + replyToMessageId: String + references: [String] + attachments: [JSON] + customerId: String + ): JSON +`;