diff --git a/kairon/__init__.py b/kairon/__init__.py
index 7fcd49789..cb4cc9f2d 100644
--- a/kairon/__init__.py
+++ b/kairon/__init__.py
@@ -44,7 +44,8 @@
def create_argument_parser():
- from kairon.cli import importer, training, testing, conversations_deletion, translator, delete_logs, message_broadcast,content_importer
+ from kairon.cli import importer, training, testing, conversations_deletion, translator, delete_logs,\
+ message_broadcast,content_importer, mail_channel
parser = ArgumentParser(
prog="kairon",
@@ -62,6 +63,7 @@ def create_argument_parser():
delete_logs.add_subparser(subparsers, parents=parent_parsers)
message_broadcast.add_subparser(subparsers, parents=parent_parsers)
content_importer.add_subparser(subparsers, parents=parent_parsers)
+ mail_channel.add_subparser(subparsers, parents=parent_parsers)
return parser
diff --git a/kairon/api/app/routers/bot/bot.py b/kairon/api/app/routers/bot/bot.py
index c2e2c8590..d267a3b84 100644
--- a/kairon/api/app/routers/bot/bot.py
+++ b/kairon/api/app/routers/bot/bot.py
@@ -25,6 +25,7 @@
from kairon.shared.account.activity_log import UserActivityLogger
from kairon.shared.actions.data_objects import ActionServerLogs
from kairon.shared.auth import Authentication
+from kairon.shared.channels.mail.data_objects import MailClassificationConfig
from kairon.shared.constants import TESTER_ACCESS, DESIGNER_ACCESS, CHAT_ACCESS, UserActivityType, ADMIN_ACCESS, \
EventClass, AGENT_ACCESS
from kairon.shared.content_importer.content_processor import ContentImporterLogProcessor
@@ -33,6 +34,7 @@
from kairon.shared.data.audit.processor import AuditDataProcessor
from kairon.shared.data.constant import ENDPOINT_TYPE, ModelTestType, \
AuditlogActions
+from kairon.shared.data.data_models import MailConfigRequest
from kairon.shared.data.data_objects import TrainingExamples, ModelTraining, Rules
from kairon.shared.data.model_processor import ModelProcessor
from kairon.shared.data.processor import MongoProcessor
@@ -1658,3 +1660,60 @@ async def get_slot_actions(
llm_models = MongoProcessor.get_slot_mapped_actions(current_user.get_bot(), slot_name)
return Response(data=llm_models)
+
+@router.get("/mail/config", response_model=Response)
+async def get_all_mail_configs(
+ current_user: User = Security(Authentication.get_current_user_and_bot, scopes=TESTER_ACCESS)):
+ """
+ Fetches mail config
+ """
+ data = MailClassificationConfig.objects(bot=current_user.get_bot(), status=True)
+ formatted_data = [
+ {key: value for key, value in item.to_mongo().items() if key not in {"_id", "user"}}
+ for item in data
+ ]
+
+ return {"data": formatted_data}
+
+
+
+@router.post("/mail/config", response_model=Response)
+async def set_mail_config(
+ request_data: MailConfigRequest,
+ current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS)
+):
+ """
+ Applies the mail config
+ """
+ request_dict = request_data.dict()
+ MailClassificationConfig.create_doc(**request_dict, bot=current_user.get_bot(), user=current_user.get_user())
+ return {"message": "Config applied!"}
+
+
+@router.put("/mail/config", response_model=Response)
+async def update_mail_config(
+ request_data: MailConfigRequest,
+ current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS)
+):
+ """
+ update the mail config
+ """
+ request_dict = request_data.dict()
+ MailClassificationConfig.update_doc(**request_dict, bot=current_user.get_bot())
+ return {"message": "Config updated!"}
+
+
+
+@router.delete("/mail/config/{intent}", response_model=Response)
+async def del_soft_mail_config(
+ intent: str,
+ current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS)
+):
+ """
+ delete the mail config
+ """
+ MailClassificationConfig.soft_delete_doc(current_user.get_bot(), intent)
+ return {"message": "Config deleted!"}
+
+
+
diff --git a/kairon/chat/agent_processor.py b/kairon/chat/agent_processor.py
index a88f27614..78c083bf3 100644
--- a/kairon/chat/agent_processor.py
+++ b/kairon/chat/agent_processor.py
@@ -78,3 +78,22 @@ async def handle_channel_message(bot: Text, userdata: UserMessage):
if not is_live_agent_enabled:
return await AgentProcessor.get_agent(bot).handle_message(userdata)
return await LiveAgentHandler.process_live_agent(bot, userdata)
+
+ @staticmethod
+ def get_agent_without_cache(bot: str, use_store: bool = True) -> Agent:
+ endpoint = AgentProcessor.mongo_processor.get_endpoints(
+ bot, raise_exception=False
+ )
+ action_endpoint = Utility.get_action_url(endpoint)
+ model_path = Utility.get_latest_model(bot)
+ domain = AgentProcessor.mongo_processor.load_domain(bot)
+ if use_store:
+ mongo_store = Utility.get_local_mongo_store(bot, domain)
+ lock_store_endpoint = Utility.get_lock_store(bot)
+ agent = KaironAgent.load(model_path, action_endpoint=action_endpoint, tracker_store=mongo_store,
+ lock_store=lock_store_endpoint)
+ else:
+ agent = KaironAgent.load(model_path, action_endpoint=action_endpoint)
+
+ agent.model_ver = model_path.split("/")[-1]
+ return agent
diff --git a/kairon/chat/utils.py b/kairon/chat/utils.py
index 6c7c7a51c..c0e909355 100644
--- a/kairon/chat/utils.py
+++ b/kairon/chat/utils.py
@@ -43,6 +43,37 @@ async def chat(
)
return chat_response
+ @staticmethod
+ async def process_messages_via_bot(
+ messages: [str],
+ account: int,
+ bot: str,
+ user: str,
+ is_integration_user: bool = False,
+ metadata: Dict = None,
+ ):
+ """Process a list of messages through the bot.
+ Args:
+ messages: List of messages to process
+ account: Account ID
+ bot: Bot ID
+ user: User ID
+ is_integration_user: Flag indicating if user is integration user
+ metadata: Additional metadata
+
+ Returns:
+ List of responses from the bot
+ """
+ responses = []
+ uncached_model = AgentProcessor.get_agent_without_cache(bot, False)
+ metadata = ChatUtils.get_metadata(account, bot, is_integration_user, metadata)
+ for message in messages:
+ msg = UserMessage(message, sender_id=user, metadata=metadata)
+ chat_response = await uncached_model.handle_message(msg)
+ responses.append(chat_response)
+ return responses
+
+
@staticmethod
def reload(bot: Text, user: Text):
exc = None
diff --git a/kairon/cli/mail_channel.py b/kairon/cli/mail_channel.py
new file mode 100644
index 000000000..589b4c8c0
--- /dev/null
+++ b/kairon/cli/mail_channel.py
@@ -0,0 +1,36 @@
+import json
+from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
+from typing import List
+from rasa.cli import SubParsersAction
+
+from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent
+
+
+def process_channel_mails(args):
+ mails = json.loads(args.mails)
+ if not isinstance(mails, list):
+ raise ValueError("Mails should be a list")
+ MailChannelScheduleEvent(args.bot, args.user).execute(mails=mails)
+
+
+def add_subparser(subparsers: SubParsersAction, parents: List[ArgumentParser]):
+ mail_parser = subparsers.add_parser(
+ "mail-channel",
+ conflict_handler="resolve",
+ formatter_class=ArgumentDefaultsHelpFormatter,
+ parents=parents,
+ help="Mail channel"
+ )
+ mail_parser.add_argument('bot',
+ type=str,
+ help="Bot id for which command is executed", action='store')
+
+ mail_parser.add_argument('user',
+ type=str,
+ help="Kairon user who is initiating the command", action='store')
+
+ mail_parser.add_argument('mails',
+ type=str,
+ help="json representing List of mails to be processed", action='store')
+
+ mail_parser.set_defaults(func=process_channel_mails)
\ No newline at end of file
diff --git a/kairon/events/definitions/factory.py b/kairon/events/definitions/factory.py
index 9ff4128f0..dd754a92f 100644
--- a/kairon/events/definitions/factory.py
+++ b/kairon/events/definitions/factory.py
@@ -2,6 +2,7 @@
from kairon.events.definitions.data_importer import TrainingDataImporterEvent
from kairon.events.definitions.faq_importer import FaqDataImporterEvent
from kairon.events.definitions.history_delete import DeleteHistoryEvent
+from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent
from kairon.events.definitions.message_broadcast import MessageBroadcastEvent
from kairon.events.definitions.model_testing import ModelTestingEvent
from kairon.events.definitions.model_training import ModelTrainingEvent
@@ -20,7 +21,8 @@ class EventFactory:
EventClass.multilingual: MultilingualEvent,
EventClass.faq_importer: FaqDataImporterEvent,
EventClass.message_broadcast: MessageBroadcastEvent,
- EventClass.content_importer: DocContentImporterEvent
+ EventClass.content_importer: DocContentImporterEvent,
+ EventClass.email_channel_scheduler: MailChannelScheduleEvent
}
@staticmethod
diff --git a/kairon/events/definitions/mail_channel_schedule.py b/kairon/events/definitions/mail_channel_schedule.py
new file mode 100644
index 000000000..6212f4740
--- /dev/null
+++ b/kairon/events/definitions/mail_channel_schedule.py
@@ -0,0 +1,51 @@
+from typing import Text
+from loguru import logger
+from kairon import Utility
+from kairon.events.definitions.base import EventsBase
+from kairon.exceptions import AppException
+from kairon.shared.channels.mail.processor import MailProcessor
+from kairon.shared.constants import EventClass
+
+
+class MailChannelScheduleEvent(EventsBase):
+ """
+ Event to start mail channel scheduler if not already running.
+ """
+
+ def __init__(self, bot: Text, user: Text, **kwargs):
+ """
+ Initialise event.
+ """
+ self.bot = bot
+ self.user = user
+
+ def validate(self):
+ """
+ validate mail channel exists
+ """
+ return MailProcessor.validate_smpt_connection(self.bot)
+
+
+ def enqueue(self, **kwargs):
+ """
+ Send event to event server.
+ """
+ try:
+ mails: list = kwargs.get('mails', [])
+ payload = {'bot': self.bot, 'user': self.user, 'mails': mails}
+ Utility.request_event_server(EventClass.email_channel_scheduler, payload)
+ except Exception as e:
+ logger.error(str(e))
+ raise AppException(e)
+
+ def execute(self, **kwargs):
+ """
+ Execute the event.
+ """
+ try:
+ mails = kwargs.get('mails')
+ if mails:
+ MailProcessor.process_message_task(self.bot, mails)
+ except Exception as e:
+ logger.error(str(e))
+ raise AppException(e)
\ No newline at end of file
diff --git a/kairon/events/server.py b/kairon/events/server.py
index 344e8528f..7aef6fffe 100644
--- a/kairon/events/server.py
+++ b/kairon/events/server.py
@@ -56,6 +56,8 @@ async def lifespan(app: FastAPI):
""" MongoDB is connected on the bot trainer startup """
config: dict = Utility.mongoengine_connection(Utility.environment['database']["url"])
connect(**config)
+ from kairon.shared.channels.mail.scheduler import MailScheduler
+ MailScheduler.epoch()
yield
disconnect()
@@ -144,3 +146,10 @@ def delete_scheduled_event(event_id: Text = Path(description="Event id")):
def dispatch_scheduled_event(event_id: Text = Path(description="Event id")):
KScheduler().dispatch_event(event_id)
return {"data": None, "message": "Scheduled event dispatch!"}
+
+
+@app.get('/api/mail/request_epoch', response_model=Response)
+def request_epoch():
+ from kairon.shared.channels.mail.scheduler import MailScheduler
+ MailScheduler.epoch()
+ return {"data": None, "message": "Mail scheduler epoch request!"}
\ No newline at end of file
diff --git a/kairon/shared/channels/mail/__init__.py b/kairon/shared/channels/mail/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/kairon/shared/channels/mail/constants.py b/kairon/shared/channels/mail/constants.py
new file mode 100644
index 000000000..a4775f15c
--- /dev/null
+++ b/kairon/shared/channels/mail/constants.py
@@ -0,0 +1,25 @@
+
+
+class MailConstants:
+ DEFAULT_SMTP_SERVER = 'smtp.gmail.com'
+ DEFAULT_IMAP_SERVER = 'imap.gmail.com'
+ DEFAULT_SMTP_PORT = 587
+ DEFAULT_LLM_TYPE = "openai"
+ DEFAULT_HYPERPARAMETERS = {
+ "frequency_penalty": 0,
+ "logit_bias": {},
+ "max_tokens": 300,
+ "model": "gpt-4o-mini",
+ "n": 1,
+ "presence_penalty": 0,
+ "stop": None,
+ "stream": False,
+ "temperature": 0,
+ "top_p": 0
+ }
+ DEFAULT_TEMPLATE = "
Dear {name},
{bot_response}
Generated by kAIron AI.\n"
+ DEFAULT_SYSTEM_PROMPT = 'Classify into one of the intents and extract entities as given in the context.' \
+ 'If the mail does not belong to any of the intents, classify intent as null.'
+
+ PROCESS_MESSAGE_BATCH_SIZE = 4
+
diff --git a/kairon/shared/channels/mail/data_objects.py b/kairon/shared/channels/mail/data_objects.py
new file mode 100644
index 000000000..513c95c43
--- /dev/null
+++ b/kairon/shared/channels/mail/data_objects.py
@@ -0,0 +1,125 @@
+import time
+from mongoengine import Document, StringField, ListField, FloatField, BooleanField
+from kairon.exceptions import AppException
+
+
+
+
+class MailClassificationConfig(Document):
+ intent: str = StringField(required=True)
+ entities: list[str] = ListField(StringField())
+ subjects: list[str] = ListField(StringField())
+ classification_prompt: str = StringField()
+ reply_template: str = StringField()
+ bot: str = StringField(required=True)
+ user: str = StringField()
+ timestamp: float = FloatField()
+ status: bool = BooleanField(default=True)
+
+
+ @staticmethod
+ def create_doc(
+ intent: str,
+ entities: list[str],
+ subjects: list[str],
+ classification_prompt: str,
+ reply_template: str,
+ bot: str,
+ user: str
+ ):
+ mail_config = None
+ try:
+ exists = MailClassificationConfig.objects(bot=bot, intent=intent).first()
+ if exists and exists.status:
+ raise AppException(f"Mail configuration already exists for intent [{intent}]")
+ elif exists and not exists.status:
+ exists.update(
+ entities=entities,
+ subjects=subjects,
+ classification_prompt=classification_prompt,
+ reply_template=reply_template,
+ timestamp=time.time(),
+ status=True,
+ user=user
+ )
+ mail_config = exists
+ else:
+ mail_config = MailClassificationConfig(
+ intent=intent,
+ entities=entities,
+ subjects=subjects,
+ classification_prompt=classification_prompt,
+ reply_template=reply_template,
+ bot=bot,
+ timestamp=time.time(),
+ status=True,
+ user=user
+ )
+ mail_config.save()
+
+ except Exception as e:
+ raise AppException(str(e))
+
+ return mail_config
+
+ @staticmethod
+ def get_docs(bot: str):
+ try:
+ objs = MailClassificationConfig.objects(bot=bot, status=True)
+ return_data = []
+ for obj in objs:
+ data = obj.to_mongo().to_dict()
+ data.pop('_id')
+ data.pop('timestamp')
+ data.pop('status')
+ data.pop('user')
+ return_data.append(data)
+ return return_data
+ except Exception as e:
+ raise AppException(str(e))
+
+ @staticmethod
+ def get_doc(bot: str, intent: str):
+ try:
+ obj = MailClassificationConfig.objects(bot=bot, intent=intent, status=True).first()
+ if not obj:
+ raise AppException(f"Mail configuration does not exist for intent [{intent}]")
+ data = obj.to_mongo().to_dict()
+ data.pop('_id')
+ data.pop('timestamp')
+ data.pop('status')
+ data.pop('user')
+ return data
+ except Exception as e:
+ raise AppException(str(e))
+
+
+ @staticmethod
+ def delete_doc(bot: str, intent: str):
+ try:
+ MailClassificationConfig.objects(bot=bot, intent=intent).delete()
+ except Exception as e:
+ raise AppException(str(e))
+
+ @staticmethod
+ def soft_delete_doc(bot: str, intent: str):
+ try:
+ MailClassificationConfig.objects(bot=bot, intent=intent).update(status=False)
+ except Exception as e:
+ raise AppException(str(e))
+
+ @staticmethod
+ def update_doc(bot: str, intent: str, **kwargs):
+ keys = ['entities', 'subjects', 'classification_prompt', 'reply_template']
+ for key in kwargs.keys():
+ if key not in keys:
+ raise AppException(f"Invalid key [{key}] provided for updating mail config")
+ try:
+ MailClassificationConfig.objects(bot=bot, intent=intent).update(**kwargs)
+ except Exception as e:
+ raise AppException(str(e))
+
+
+
+
+
diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py
new file mode 100644
index 000000000..821b6cd28
--- /dev/null
+++ b/kairon/shared/channels/mail/processor.py
@@ -0,0 +1,272 @@
+import asyncio
+import re
+
+from loguru import logger
+from pydantic.schema import timedelta
+from pydantic.validators import datetime
+from imap_tools import MailBox, AND
+from kairon.exceptions import AppException
+from kairon.shared.account.data_objects import Bot
+from kairon.shared.channels.mail.constants import MailConstants
+from kairon.shared.channels.mail.data_objects import MailClassificationConfig
+from kairon.shared.chat.processor import ChatDataProcessor
+from kairon.shared.constants import ChannelTypes
+from kairon.shared.data.data_objects import BotSettings
+from kairon.shared.llm.processor import LLMProcessor
+import json
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+import smtplib
+
+
+
+class MailProcessor:
+ def __init__(self, bot):
+ self.config = ChatDataProcessor.get_channel_config(ChannelTypes.MAIL, bot, False)['config']
+ self.llm_type = self.config.get('llm_type', "openai")
+ self.hyperparameters = self.config.get('hyperparameters', MailConstants.DEFAULT_HYPERPARAMETERS)
+ self.bot = bot
+ bot_info = Bot.objects.get(id=bot)
+ self.account = bot_info.account
+ self.llm_processor = LLMProcessor(self.bot, self.llm_type)
+ self.mail_configs = list(MailClassificationConfig.objects(bot=self.bot))
+ self.mail_configs_dict = {item.intent: item for item in self.mail_configs}
+ self.bot_settings = BotSettings.objects(bot=self.bot).get()
+ self.mailbox = None
+ self.smtp = None
+
+
+ def login_imap(self):
+ if self.mailbox:
+ return
+ email_account = self.config['email_account']
+ email_password = self.config['email_password']
+ imap_server = self.config.get('imap_server', MailConstants.DEFAULT_IMAP_SERVER)
+ self.mailbox = MailBox(imap_server).login(email_account, email_password)
+
+ def logout_imap(self):
+ if self.mailbox:
+ self.mailbox.logout()
+ self.mailbox = None
+
+ def login_smtp(self):
+ if self.smtp:
+ return
+ email_account = self.config['email_account']
+ email_password = self.config['email_password']
+ smtp_server = self.config.get('smtp_server', MailConstants.DEFAULT_SMTP_SERVER)
+ smtp_port = self.config.get('smtp_port', MailConstants.DEFAULT_SMTP_PORT)
+ smtp_port = int(smtp_port)
+ self.smtp = smtplib.SMTP(smtp_server, smtp_port, timeout=30)
+ self.smtp.starttls()
+ self.smtp.login(email_account, email_password)
+
+ def logout_smtp(self):
+ if self.smtp:
+ self.smtp.quit()
+ self.smtp = None
+
+
+ @staticmethod
+ def validate_smpt_connection(bot):
+ try:
+ mp = MailProcessor(bot)
+ mp.login_smtp()
+ mp.logout_smtp()
+ return True
+ except Exception as e:
+ logger.error(str(e))
+ return False
+
+ async def send_mail(self, to: str, subject: str, body: str):
+ try:
+ email_account = self.config['email_account']
+ msg = MIMEMultipart()
+ msg['From'] = email_account
+ msg['To'] = to
+ msg['Subject'] = subject
+ msg.attach(MIMEText(body, 'html'))
+ self.smtp.sendmail(email_account, to, msg.as_string())
+ except Exception as e:
+ logger.error(f"Error sending mail to {to}: {str(e)}")
+
+ def process_mail(self, intent: str, rasa_chat_response: dict):
+ slots = rasa_chat_response.get('slots', [])
+ slots = {key.strip(): value.strip() for slot_str in slots
+ for split_result in [slot_str.split(":", 1)]
+ if len(split_result) == 2
+ for key, value in [split_result]}
+
+
+ responses = '
'.join(response.get('text', '') for response in rasa_chat_response.get('response', []))
+ slots['bot_response'] = responses
+ mail_template = self.mail_configs_dict.get(intent, None)
+ if mail_template and mail_template.reply_template:
+ mail_template = mail_template.reply_template
+ else:
+ mail_template = MailConstants.DEFAULT_TEMPLATE
+
+ return mail_template.format(**{key: str(value) for key, value in slots.items()})
+
+ async def classify_messages(self, messages: [dict]) -> [dict]:
+ if self.bot_settings.llm_settings['enable_faq']:
+ try:
+ system_prompt = self.config.get('system_prompt', MailConstants.DEFAULT_SYSTEM_PROMPT)
+ system_prompt += '\n return json format: [{"intent": "intent_name", "entities": {"entity_name": "value"}, "mail_id": "mail_id", "subject": "subject"}], if not classifiable set intent and not-found entity values as null'
+ context_prompt = self.get_context_prompt()
+ messages = json.dumps(messages)
+ info = await self.llm_processor.predict(messages,
+ self.bot_settings.user,
+ system_prompt=system_prompt,
+ context_prompt=context_prompt,
+ similarity_prompt=[],
+ hyperparameters=self.hyperparameters)
+ classifications = MailProcessor.extract_jsons_from_text(info["content"])[0]
+ return classifications
+ except Exception as e:
+ logger.error(str(e))
+ raise AppException(str(e))
+
+
+ @staticmethod
+ async def process_messages(bot: str, batch: [dict]):
+ """
+ classify and respond to a batch of messages
+ """
+ try:
+ from kairon.chat.utils import ChatUtils
+ mp = MailProcessor(bot)
+ classifications = await mp.classify_messages(batch)
+ user_messages: [str] = []
+ responses = []
+ intents = []
+ for classification in classifications:
+ try:
+ intent = classification['intent']
+ if not intent or intent == 'null':
+ continue
+ entities = classification['entities']
+ sender_id = classification['mail_id']
+ subject = f"{classification['subject']}"
+
+ # mail_id is in the format "name " or "email"
+ if '<' in sender_id:
+ sender_id = sender_id.split('<')[1].split('>')[0]
+
+ entities_str = ', '.join([f'"{key}": "{value}"' for key, value in entities.items() if value and value != 'null'])
+ user_msg = f'/{intent}{{{entities_str}}}'
+ logger.info(user_msg)
+
+ user_messages.append(user_msg)
+ responses.append({
+ 'to': sender_id,
+ 'subject': subject,
+ })
+ intents.append(intent)
+ except Exception as e:
+ logger.exception(e)
+ logger.info(responses)
+
+ chat_responses = await ChatUtils.process_messages_via_bot(user_messages,
+ mp.account,
+ bot,
+ mp.bot_settings.user,
+ False,
+ {
+ 'channel': ChannelTypes.MAIL.value
+ })
+ logger.info(chat_responses)
+
+ for index, response in enumerate(chat_responses):
+ responses[index]['body'] = mp.process_mail(intents[index], response)
+
+ mp.login_smtp()
+ tasks = [mp.send_mail(**response) for response in responses]
+ await asyncio.gather(*tasks)
+ mp.logout_smtp()
+
+ except Exception as e:
+ raise AppException(str(e))
+
+ def get_context_prompt(self) -> str:
+ context_prompt = ""
+ for item in self.mail_configs:
+ context_prompt += f"intent: {item['intent']} \n"
+ context_prompt += f"entities: {item['entities']} \n"
+ context_prompt += "\nclassification criteria: \n"
+ context_prompt += f"subjects: {item['subjects']} \n"
+ context_prompt += f"rule: {item['classification_prompt']} \n"
+ context_prompt += "\n\n"
+ return context_prompt
+
+
+ @staticmethod
+ def process_message_task(bot: str, message_batch: [dict]):
+ """
+ Process a batch of messages
+ used for execution by executor
+ """
+ asyncio.run(MailProcessor.process_messages(bot, message_batch))
+
+
+ @staticmethod
+ def read_mails(bot: str) -> ([dict], str, int):
+ """
+ Read mails from the mailbox
+ Parameters:
+ - bot: str - bot id
+ Returns:
+ - list of messages - each message is a dict with the following
+ - mail_id
+ - subject
+ - date
+ - body
+ - user
+ - time_shift
+
+ """
+ mp = MailProcessor(bot)
+ time_shift = int(mp.config.get('interval', 20 * 60))
+ last_read_timestamp = datetime.now() - timedelta(seconds=time_shift)
+ messages = []
+ is_logged_in = False
+ try:
+ mp.login_imap()
+ is_logged_in = True
+ msgs = mp.mailbox.fetch(AND(seen=False, date_gte=last_read_timestamp.date()))
+ for msg in msgs:
+ subject = msg.subject
+ sender_id = msg.from_
+ date = msg.date
+ body = msg.text or msg.html or ""
+ logger.info(subject, sender_id, date)
+ message_entry = {
+ 'mail_id': sender_id,
+ 'subject': subject,
+ 'date': str(date),
+ 'body': body
+ }
+ messages.append(message_entry)
+ mp.logout_imap()
+ is_logged_in = False
+ return messages, mp.bot_settings.user, time_shift
+ except Exception as e:
+ logger.exception(e)
+ if is_logged_in:
+ mp.logout_imap()
+ return [], mp.bot_settings.user, time_shift
+
+ @staticmethod
+ def extract_jsons_from_text(text) -> list:
+ """
+ Extract json objects from text as a list
+ """
+ json_pattern = re.compile(r'(\{.*?\}|\[.*?\])', re.DOTALL)
+ jsons = []
+ for match in json_pattern.findall(text):
+ try:
+ json_obj = json.loads(match)
+ jsons.append(json_obj)
+ except json.JSONDecodeError:
+ continue
+ return jsons
diff --git a/kairon/shared/channels/mail/scheduler.py b/kairon/shared/channels/mail/scheduler.py
new file mode 100644
index 000000000..4d447bb0a
--- /dev/null
+++ b/kairon/shared/channels/mail/scheduler.py
@@ -0,0 +1,94 @@
+from datetime import datetime, timedelta
+from urllib.parse import urljoin
+
+from apscheduler.jobstores.mongodb import MongoDBJobStore
+from apscheduler.schedulers.background import BackgroundScheduler
+from pymongo import MongoClient
+
+from kairon import Utility
+from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent
+from kairon.exceptions import AppException
+from kairon.shared.channels.mail.processor import MailProcessor
+from kairon.shared.chat.data_objects import Channels
+from kairon.shared.constants import ChannelTypes
+from loguru import logger
+
+
+class MailScheduler:
+ scheduler = None
+ scheduled_bots = set()
+
+ @staticmethod
+ def epoch():
+ is_initialized = False
+ if not MailScheduler.scheduler:
+ is_initialized = True
+ client = MongoClient(Utility.environment['database']['url'])
+ events_db = Utility.environment['events']['queue']['mail_queue_name']
+ job_store_name = Utility.environment['events']['scheduler']['mail_scheduler_collection']
+
+ MailScheduler.scheduler = BackgroundScheduler(
+ jobstores={job_store_name: MongoDBJobStore(events_db, job_store_name, client)},
+ job_defaults={'coalesce': True, 'misfire_grace_time': 7200})
+
+ bots = Channels.objects(connector_type= ChannelTypes.MAIL)
+ bots_list = [bot['bot'] for bot in bots]
+ bots = set(bots_list)
+
+ unscheduled_bots = bots - MailScheduler.scheduled_bots
+ for bot in unscheduled_bots:
+ first_schedule_time = datetime.now() + timedelta(seconds=5)
+ MailScheduler.scheduler.add_job(MailScheduler.process_mails,
+ 'date', args=[bot, MailScheduler.scheduler], run_date=first_schedule_time)
+ MailScheduler.scheduled_bots.add(bot)
+
+ MailScheduler.scheduled_bots = MailScheduler.scheduled_bots.intersection(bots)
+ if is_initialized:
+ MailScheduler.scheduler.start()
+ return True
+ return False
+
+ @staticmethod
+ def request_epoch():
+ event_server_url = Utility.get_event_server_url()
+ resp = Utility.execute_http_request(
+ "GET",
+ urljoin(
+ event_server_url,
+ "/api/mail/request_epoch",
+ ),
+ err_msg="Failed to request epoch",
+ )
+ if not resp['success']:
+ raise AppException("Failed to request email channel epoch")
+
+ @staticmethod
+ def process_mails(bot, scheduler: BackgroundScheduler = None):
+
+ if bot not in MailScheduler.scheduled_bots:
+ return
+ logger.info(f"MailScheduler: Processing mails for bot {bot}")
+ next_timestamp = MailScheduler.read_mailbox_and_schedule_events(bot)
+ MailScheduler.scheduler.add_job(MailScheduler.process_mails, 'date', args=[bot, scheduler], run_date=next_timestamp)
+ MailScheduler.epoch()
+
+ @staticmethod
+ def read_mailbox_and_schedule_events(bot) -> datetime:
+ vals = MailProcessor.read_mails(bot)
+ print(vals)
+ emails, user, next_delay = vals
+ for email in emails:
+ ev = MailChannelScheduleEvent(bot, user)
+ ev.validate()
+ ev.enqueue(mails=[email])
+ next_timestamp = datetime.now() + timedelta(seconds=next_delay)
+ return next_timestamp
+
+
+
+
+
+
+
+
+
diff --git a/kairon/shared/chat/processor.py b/kairon/shared/chat/processor.py
index 43d136e50..208331b35 100644
--- a/kairon/shared/chat/processor.py
+++ b/kairon/shared/chat/processor.py
@@ -39,6 +39,9 @@ def save_channel_config(configuration: Dict, bot: Text, user: Text):
channel.user = user
channel.timestamp = datetime.utcnow()
channel.save()
+ if configuration['connector_type'] == ChannelTypes.MAIL.value:
+ from kairon.shared.channels.mail.scheduler import MailScheduler
+ MailScheduler.request_epoch()
if primary_slack_config_changed:
ChatDataProcessor.delete_channel_config(bot, connector_type="slack", config__is_primary=False)
channel_endpoint = DataUtility.get_channel_endpoint(channel)
diff --git a/kairon/shared/constants.py b/kairon/shared/constants.py
index 3068384df..66419bde0 100644
--- a/kairon/shared/constants.py
+++ b/kairon/shared/constants.py
@@ -80,6 +80,7 @@ class EventClass(str, Enum):
web_search = "web_search"
scheduler_evaluator = "scheduler_evaluator"
content_importer = "content_importer"
+ email_channel_scheduler = "email_channel_scheduler"
class EventRequestType(str, Enum):
@@ -115,7 +116,7 @@ class ChannelTypes(str, Enum):
INSTAGRAM = "instagram"
BUSINESS_MESSAGES = "business_messages"
LINE = "line"
-
+ MAIL = "mail"
class ElementTypes(str, Enum):
LINK = "link"
diff --git a/kairon/shared/data/data_models.py b/kairon/shared/data/data_models.py
index 346e0707f..1fcc76791 100644
--- a/kairon/shared/data/data_models.py
+++ b/kairon/shared/data/data_models.py
@@ -1341,3 +1341,11 @@ def validate_name(cls, values):
raise ValueError("Schedule action can not be empty, it is needed to execute on schedule time")
return values
+
+
+class MailConfigRequest(BaseModel):
+ intent: str
+ entities: list[str] = []
+ subjects: list[str] = []
+ classification_prompt: str
+ reply_template: str = None
\ No newline at end of file
diff --git a/metadata/integrations.yml b/metadata/integrations.yml
index c767e5c18..f2792000c 100644
--- a/metadata/integrations.yml
+++ b/metadata/integrations.yml
@@ -75,6 +75,16 @@ channels:
required_fields:
- channel_secret
- channel_access_token
+ mail:
+ required_fields:
+ - email_account
+ - email_password
+ - imap_server
+ - smtp_server
+ - smtp_port
+ optional_fields:
+ - interval
+ - llm_type
actions:
pipedrive:
diff --git a/requirements/prod.txt b/requirements/prod.txt
index 1e8ad7217..f3e71dffd 100644
--- a/requirements/prod.txt
+++ b/requirements/prod.txt
@@ -67,4 +67,5 @@ jsonschema_rs==0.18.1
mongoengine-jsonschema==0.1.3
fernet==1.0.1
google-generativeai
-huggingface-hub==0.25.2
\ No newline at end of file
+huggingface-hub==0.25.2
+imap-tools==1.7.4
\ No newline at end of file
diff --git a/system.yaml b/system.yaml
index 5c98fb7a5..a6d73183e 100644
--- a/system.yaml
+++ b/system.yaml
@@ -127,6 +127,7 @@ events:
type: ${EVENTS_QUEUE_TYPE:"mongo"}
url: ${EVENTS_QUEUE_URL:"mongodb://localhost:27017/events"}
name: ${EVENTS_DB_NAME:"kairon_events"}
+ mail_queue_name: ${EVENTS_MAIL_QUEUE_NAME:"mail_queue"}
task_definition:
model_training: ${MODEL_TRAINING_TASK_DEFINITION}
model_testing: ${MODEL_TESTING_TASK_DEFINITION}
@@ -141,6 +142,7 @@ events:
content_importer: ${DOC_CONTENT_IMPORTER_TASK_DEFINITION}
scheduler:
collection: ${EVENT_SCHEDULER_COLLECTION:"kscheduler"}
+ mail_scheduler_collection: ${MAIL_SCHEDULER_COLLECTION:"mail_scheduler"}
type: ${EVENT_SCHEDULER_TYPE:"kscheduler"}
min_trigger_interval: ${MIN_SCHDULER_TRIGGER_INTERVAL:86340}
audit_logs:
@@ -153,7 +155,7 @@ evaluator:
url: ${EXPRESSION_EVALUATOR_ENDPOINT:"http://192.168.100.109:8085/evaluate"}
pyscript:
trigger_task: ${PYSCRIPT_TRIGGER_TASK:false}
- url: ${PYSCRIPT_EVALUATOR_ENDPOINT:"http://192.168.100.109:8087/evaluate"}
+ url: ${PYSCRIPT_EVALUATOR_ENDPOINT:"http://localhost:8087/evaluate"}
multilingual:
enable: ${ENABLE_MULTILINGUAL_BOTS:false}
diff --git a/tests/integration_test/action_service_test.py b/tests/integration_test/action_service_test.py
index 3f4551f45..35f50340c 100644
--- a/tests/integration_test/action_service_test.py
+++ b/tests/integration_test/action_service_test.py
@@ -2,6 +2,10 @@
import datetime
import os
from urllib.parse import urlencode, urljoin
+from kairon.shared.utils import Utility
+os.environ["system_file"] = "./tests/testing_data/system.yaml"
+Utility.load_environment()
+Utility.load_system_metadata()
import urllib
import litellm
@@ -11,18 +15,17 @@
import responses
import ujson as json
from apscheduler.util import obj_to_ref
-from cycler import U
from deepdiff import DeepDiff
from fastapi.testclient import TestClient
from jira import JIRAError
from litellm import embedding
from mongoengine import connect
+
+
from kairon.events.executors.factory import ExecutorFactory
from kairon.shared.callback.data_objects import CallbackConfig, encrypt_secret
-from kairon.shared.utils import Utility
-Utility.load_system_metadata()
from kairon.actions.definitions.live_agent import ActionLiveAgent
from kairon.actions.definitions.set_slot import ActionSetSlot
@@ -11752,121 +11755,6 @@ def test_prompt_action_response_action_with_prompt_question_from_slot(mock_embed
'response': None, 'image': None, 'attachment': None}
]
-@mock.patch.object(litellm, "aembedding", autospec=True)
-@mock.patch.object(ActionUtility, 'execute_request_async', autospec=True)
-def test_prompt_action_response_action_with_prompt_question_from_slot_perplexity(mock_execute_request_async, mock_embedding, aioresponses):
- from uuid6 import uuid7
- llm_type = "perplexity"
- action_name = "test_prompt_action_response_action_with_prompt_question_from_slot"
- bot = "5f50fd0a56b69s8ca10d35d2l"
- user = "udit.pandey"
- value = "keyvalue"
- user_msg = "What kind of language is python?"
- bot_content = "Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. Python is dynamically typed and garbage-collected."
- generated_text = "Python is dynamically typed, garbage-collected, high level, general purpose programming."
- llm_prompts = [
- {'name': 'System Prompt',
- 'data': 'You are a personal assistant. Answer question based on the context below.',
- 'type': 'system', 'source': 'static', 'is_enabled': True},
- {'name': 'History Prompt', 'type': 'user', 'source': 'history', 'is_enabled': True},
- {'name': 'Query Prompt', 'data': "What kind of language is python?", 'instructions': 'Rephrase the query.',
- 'type': 'query', 'source': 'static', 'is_enabled': False},
- {'name': 'Similarity Prompt',
- 'instructions': 'Answer question based on the context above, if answer is not in the context go check previous logs.',
- 'type': 'user', 'source': 'bot_content', 'data': 'python',
- 'hyperparameters': {"top_results": 10, "similarity_threshold": 0.70},
- 'is_enabled': True}
- ]
- mock_execute_request_async.return_value = (
- {
- 'formatted_response': 'Python is dynamically typed, garbage-collected, high level, general purpose programming.',
- 'response': 'Python is dynamically typed, garbage-collected, high level, general purpose programming.'},
- 200,
- mock.ANY,
- mock.ANY
- )
- embedding = list(np.random.random(OPENAI_EMBEDDING_OUTPUT))
- mock_embedding.return_value = litellm.EmbeddingResponse(**{'data': [{'embedding': embedding}]})
- expected_body = {'messages': [
- {'role': 'system', 'content': 'You are a personal assistant. Answer question based on the context below.\n'},
- {'role': 'user', 'content': 'hello'}, {'role': 'assistant', 'content': 'how are you'}, {'role': 'user',
- 'content': "\nInstructions on how to use Similarity Prompt:\n['Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. Python is dynamically typed and garbage-collected.']\nAnswer question based on the context above, if answer is not in the context go check previous logs.\n \nQ: What kind of language is python? \nA:"}],
- 'metadata': {'user': 'udit.pandey', 'bot': '5f50fd0a56b698ca10d35d2l', 'invocation': 'prompt_action'},
- 'api_key': 'keyvalue',
- 'num_retries': 3, 'temperature': 0.0, 'max_tokens': 300, 'model': 'gpt-4o-mini', 'top_p': 0.0, 'n': 1,
- 'stop': None, 'presence_penalty': 0.0, 'frequency_penalty': 0.0, 'logit_bias': {}}
- aioresponses.add(
- url=urljoin(Utility.environment['llm']['url'],
- f"/{bot}/completion/{llm_type}"),
- method="POST",
- status=200,
- payload={'formatted_response': generated_text, 'response': generated_text},
- body=json.dumps(expected_body)
- )
- aioresponses.add(
- url=f"{Utility.environment['vector']['db']}/collections/{bot}_python_faq_embd/points/search",
- body={'vector': embedding},
- payload={'result': [{'id': uuid7().__str__(), 'score': 0.80, 'payload': {'content': bot_content}}]},
- method="POST",
- status=200
- )
- hyperparameters = Utility.get_llm_hyperparameters("perplexity")
- hyperparameters['search_domain_filter'] = ["domain1.com", "domain2.com"]
- Actions(name=action_name, type=ActionType.prompt_action.value, bot=bot, user=user).save()
- BotSettings(llm_settings=LLMSettings(enable_faq=True), bot=bot, user=user).save()
- PromptAction(name=action_name, bot=bot, user=user, num_bot_responses=2, llm_prompts=llm_prompts, llm_type="perplexity", hyperparameters = hyperparameters,
- user_question=UserQuestion(type="from_slot", value="prompt_question")).save()
- llm_secret = LLMSecret(
- llm_type=llm_type,
- api_key=value,
- models=["perplexity/llama-3.1-sonar-small-128k-online", "perplexity/llama-3.1-sonar-large-128k-online", "perplexity/llama-3.1-sonar-huge-128k-online"],
- bot=bot,
- user=user
- )
- llm_secret.save()
- llm_secret = LLMSecret(
- llm_type="openai",
- api_key="api_key",
- models=["gpt-3.5-turbo", "gpt-4o-mini"],
- bot=bot,
- user=user
- )
- llm_secret.save()
- request_object = json.load(open("tests/testing_data/actions/action-request.json"))
- request_object["tracker"]["slots"] = {"bot": bot, "prompt_question": user_msg}
- request_object["next_action"] = action_name
- request_object["tracker"]["sender_id"] = user
- request_object['tracker']['events'] = [{"event": "user", 'text': 'hello',
- "data": {"elements": '', "quick_replies": '', "buttons": '',
- "attachment": '', "image": '', "custom": ''}},
- {'event': 'bot', "text": "how are you",
- "data": {"elements": '', "quick_replies": '', "buttons": '',
- "attachment": '', "image": '', "custom": ''}}]
- response = client.post("/webhook", json=request_object)
- response_json = response.json()
- mock_execute_request_async.assert_called_once_with(
- http_url=f"{Utility.environment['llm']['url']}/{urllib.parse.quote(bot)}/completion/{llm_type}",
- request_method="POST",
- request_body={
- 'messages': [{'role': 'system', 'content': 'You are a personal assistant. Answer question based on the context below.\n'},
- {'role': 'user', 'content': 'hello'},
- {'role': 'assistant', 'content': 'how are you'},
- {'role': 'user', 'content': "\nInstructions on how to use Similarity Prompt:\n['Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. Python is dynamically typed and garbage-collected.']\nAnswer question based on the context above, if answer is not in the context go check previous logs.\n \nQ: What kind of language is python? inurl:domain1.com|domain2.com \nA:"}],
- 'hyperparameters': hyperparameters,
- 'user': user,
- 'invocation': "prompt_action"
- },
- timeout=Utility.environment['llm'].get('request_timeout', 30)
- )
- called_args = mock_execute_request_async.call_args
- user_message = called_args.kwargs['request_body']['messages'][-1]['content']
- assert "inurl:domain1.com|domain2.com" in user_message
- assert response_json['events'] == [
- {'event': 'slot', 'timestamp': None, 'name': 'kairon_action_response', 'value': generated_text}]
- assert response_json['responses'] == [
- {'text': generated_text, 'buttons': [], 'elements': [], 'custom': {}, 'template': None,
- 'response': None, 'image': None, 'attachment': None}
- ]
@mock.patch.object(litellm, "aembedding", autospec=True)
def test_prompt_action_response_action_with_prompt_question_from_slot_different_embedding_completion(mock_embedding, aioresponses):
diff --git a/tests/integration_test/event_service_test.py b/tests/integration_test/event_service_test.py
index 46135d175..55e859ace 100644
--- a/tests/integration_test/event_service_test.py
+++ b/tests/integration_test/event_service_test.py
@@ -533,4 +533,15 @@ def test_scheduled_event_request_dispatch(mock_dispatch_event):
assert isinstance(args[1], JobEvent)
assert isinstance(args[0], BackgroundScheduler)
-os_patch.stop()
\ No newline at end of file
+
+@patch('kairon.shared.channels.mail.scheduler.MailScheduler.epoch')
+def test_request_epoch(mock_epoch):
+ response = client.get('/api/mail/request_epoch')
+ mock_epoch.assert_called_once()
+ assert response.status_code == 200
+ resp = response.json()
+ assert resp['data'] is None
+ assert resp['success']
+
+os_patch.stop()
+
diff --git a/tests/integration_test/services_test.py b/tests/integration_test/services_test.py
index 86d94605e..92124f90e 100644
--- a/tests/integration_test/services_test.py
+++ b/tests/integration_test/services_test.py
@@ -4406,6 +4406,129 @@ def test_get_live_agent_after_disabled():
assert actual["success"]
+
+
+def test_add_mail_config():
+ data = {
+ "intent": "greet",
+ "entities": ["name", "subject", "summery"],
+ "classification_prompt": "any personal mail of greeting"
+ }
+
+ response = client.post(
+ f"/api/bot/{pytest.bot}/mail/config",
+ json=data,
+ headers={"Authorization": pytest.token_type + " " + pytest.access_token},
+ )
+ actual = response.json()
+ print(actual)
+ assert actual["success"]
+ assert actual['message'] == 'Config applied!'
+ assert actual['error_code'] == 0
+
+def test_add_mail_config_missing_field():
+ data = {
+ "entities": ["name", "subject", "summery"],
+ }
+
+ response = client.post(
+ f"/api/bot/{pytest.bot}/mail/config",
+ json=data,
+ headers={"Authorization": pytest.token_type + " " + pytest.access_token},
+ )
+ actual = response.json()
+ print(actual)
+ assert not actual["success"]
+ assert len(actual['message'])
+ assert actual['error_code'] == 422
+
+def test_add_mail_config_same_intent():
+ data = {
+ "intent": "greet",
+ "entities": ["name", "subject", "summery"],
+ "classification_prompt": "any personal mail of greeting"
+ }
+
+ response = client.post(
+ f"/api/bot/{pytest.bot}/mail/config",
+ json=data,
+ headers={"Authorization": pytest.token_type + " " + pytest.access_token},
+ )
+ actual = response.json()
+ print(actual)
+ assert not actual["success"]
+ assert actual['message'] == 'Mail configuration already exists for intent [greet]'
+ assert actual['error_code'] == 422
+
+
+def test_get_mail_config():
+
+ response = client.get(
+ f"/api/bot/{pytest.bot}/mail/config",
+ headers={"Authorization": pytest.token_type + " " + pytest.access_token},
+ )
+ actual = response.json()
+ print(actual)
+ assert actual["success"]
+ assert len(actual['data']) == 1
+ assert actual['data'][0]['intent'] == 'greet'
+ assert actual['error_code'] == 0
+
+
+
+def test_update_mail_config():
+ data = {
+ "intent": "greet",
+ "entities": ["name", "subject", "summery"],
+ "classification_prompt": "any personal email of greeting"
+ }
+
+ response = client.put(
+ f"/api/bot/{pytest.bot}/mail/config",
+ json=data,
+ headers={"Authorization": pytest.token_type + " " + pytest.access_token},
+ )
+ actual = response.json()
+ print(actual)
+ assert actual["success"]
+ assert actual['message'] == 'Config updated!'
+ assert actual['error_code'] == 0
+
+ response = client.get(
+ f"/api/bot/{pytest.bot}/mail/config",
+ headers={"Authorization": pytest.token_type + " " + pytest.access_token},
+ )
+ actual = response.json()
+ print(actual)
+ assert actual["success"]
+ assert len(actual['data']) == 1
+ assert actual['data'][0]['intent'] == 'greet'
+ assert actual['data'][0]['classification_prompt'] == 'any personal email of greeting'
+ assert actual['error_code'] == 0
+
+
+def test_delete_mail_config():
+ response = client.delete(
+ f"/api/bot/{pytest.bot}/mail/config/greet",
+ headers={"Authorization": pytest.token_type + " " + pytest.access_token},
+ )
+
+ actual = response.json()
+ assert actual['success']
+ assert actual['message'] == 'Config deleted!'
+
+ response = client.get(
+ f"/api/bot/{pytest.bot}/mail/config",
+ headers={"Authorization": pytest.token_type + " " + pytest.access_token},
+ )
+ actual = response.json()
+ print(actual)
+ assert actual["success"]
+ assert len(actual['data']) == 0
+
+
+
+
def test_callback_config_add_syntax_error():
request_body = {
"name": "callback_1",
@@ -4746,6 +4869,7 @@ def test_callback_action_delete():
assert actual == {'success': True, 'message': 'Callback action deleted successfully!', 'data': None, 'error_code': 0}
+
def test_add_pyscript_action_empty_name():
script = "bot_response='hello world'"
request_body = {
@@ -23867,6 +23991,9 @@ def test_add_channel_config_error():
)
+
+
+
def test_add_bot_with_template_name(monkeypatch):
from kairon.shared.admin.data_objects import BotSecrets
diff --git a/tests/testing_data/system.yaml b/tests/testing_data/system.yaml
index d9dffe97b..22d24b5b2 100644
--- a/tests/testing_data/system.yaml
+++ b/tests/testing_data/system.yaml
@@ -121,6 +121,7 @@ events:
type: ${EVENTS_QUEUE_TYPE:"mongo"}
url: ${EVENTS_QUEUE_URL:"mongodb://localhost:27017/events"}
name: ${EVENTS_DB_NAME:"kairon_events"}
+ mail_queue_name: ${EVENTS_MAIL_QUEUE_NAME:"mail_queue"}
task_definition:
model_training: ${MODEL_TRAINING_TASK_DEFINITION}
model_testing: ${MODEL_TESTING_TASK_DEFINITION}
@@ -140,6 +141,7 @@ events:
- bot
scheduler:
collection: ${EVENT_SCHEDULER_COLLECTION:"kscheduler"}
+ mail_scheduler_collection: ${MAIL_SCHEDULER_COLLECTION:"mail_scheduler"}
type: ${EVENT_SCHEDULER_TYPE:"kscheduler"}
min_trigger_interval: ${MIN_SCHDULER_TRIGGER_INTERVAL:86340}
diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py
new file mode 100644
index 000000000..b8ad61c5d
--- /dev/null
+++ b/tests/unit_test/channels/mail_channel_test.py
@@ -0,0 +1,740 @@
+import asyncio
+import os
+from unittest.mock import patch, MagicMock
+
+import pytest
+from imap_tools import MailMessage
+
+from mongoengine import connect, disconnect
+
+from kairon import Utility
+os.environ["system_file"] = "./tests/testing_data/system.yaml"
+Utility.load_environment()
+Utility.load_system_metadata()
+
+from kairon.shared.account.data_objects import Bot, Account
+from kairon.shared.channels.mail.constants import MailConstants
+from kairon.shared.channels.mail.processor import MailProcessor
+from kairon.shared.chat.data_objects import Channels
+from kairon.shared.chat.processor import ChatDataProcessor
+from kairon.shared.data.data_objects import BotSettings
+
+from kairon.shared.channels.mail.data_objects import MailClassificationConfig
+from kairon.exceptions import AppException
+from kairon.shared.constants import ChannelTypes
+
+
+
+class TestMailChannel:
+ @pytest.fixture(autouse=True, scope='class')
+ def setup(self):
+ connect(**Utility.mongoengine_connection(Utility.environment['database']["url"]))
+
+ yield
+
+ self.remove_basic_data()
+ disconnect()
+
+ def create_basic_data(self):
+ a = Account.objects.create(name="mail_channel_test_user_acc", user="mail_channel_test_user_acc")
+ bot = Bot.objects.create(name="mail_channel_test_bot", user="mail_channel_test_user_acc", status=True, account=a.id)
+ pytest.mail_test_bot = str(bot.id)
+ b = BotSettings.objects.create(bot=pytest.mail_test_bot, user="mail_channel_test_user_acc")
+ # b.llm_settings.enable_faq = True
+ b.save()
+ ChatDataProcessor.save_channel_config(
+ {
+ "connector_type": ChannelTypes.MAIL.value,
+ "config": {
+ 'email_account': "mail_channel_test_user_acc@testuser.com",
+ 'email_password': "password",
+ 'imap_server': "imap.testuser.com",
+ 'smtp_server': "smtp.testuser.com",
+ 'smtp_port': "587",
+ }
+ },
+ pytest.mail_test_bot,
+ user="mail_channel_test_user_acc",
+ )
+
+ def remove_basic_data(self):
+ MailClassificationConfig.objects.delete()
+ BotSettings.objects(user="mail_channel_test_user_acc").delete()
+ Bot.objects(user="mail_channel_test_user_acc").delete()
+ Account.objects(user="mail_channel_test_user_acc").delete()
+ Channels.objects(connector_type=ChannelTypes.MAIL.value).delete()
+
+ @patch("kairon.shared.utils.Utility.execute_http_request")
+ def test_create_doc_new_entry(self, execute_http_request):
+ self.create_basic_data()
+ execute_http_request.return_value = {"success": True}
+ print(pytest.mail_test_bot)
+ doc = MailClassificationConfig.create_doc(
+ intent="greeting",
+ entities=["user_name"],
+ subjects=["hello"],
+ classification_prompt="Classify this email as a greeting.",
+ reply_template="Hi, how can I help?",
+ bot=pytest.mail_test_bot,
+ user="mail_channel_test_user_acc"
+ )
+ assert doc.intent == "greeting"
+ assert doc.bot == pytest.mail_test_bot
+ assert doc.status is True
+ MailClassificationConfig.objects.delete()
+
+
+
+ def test_create_doc_existing_active_entry(self):
+ MailClassificationConfig.create_doc(
+ intent="greeting",
+ entities=["user_name"],
+ subjects=["hello"],
+ classification_prompt="Classify this email as a greeting.",
+ reply_template="Hi, how can I help?",
+ bot=pytest.mail_test_bot,
+ user="mail_channel_test_user_acc"
+ )
+ with pytest.raises(AppException, match=r"Mail configuration already exists for intent \[greeting\]"):
+ MailClassificationConfig.create_doc(
+ intent="greeting",
+ entities=["user_email"],
+ subjects=["hi"],
+ classification_prompt="Another greeting.",
+ reply_template="Hello!",
+ bot=pytest.mail_test_bot,
+ user="mail_channel_test_user_acc"
+ )
+ MailClassificationConfig.objects.delete()
+
+
+
+ def test_get_docs(self):
+ MailClassificationConfig.create_doc(
+ intent="greeting",
+ entities=["user_name"],
+ subjects=["hello"],
+ classification_prompt="Classify this email as a greeting.",
+ reply_template="Hi, how can I help?",
+ bot=pytest.mail_test_bot,
+ user="mail_channel_test_user_acc"
+ )
+ MailClassificationConfig.create_doc(
+ intent="goodbye",
+ entities=["farewell"],
+ subjects=["bye"],
+ classification_prompt="Classify this email as a goodbye.",
+ reply_template="Goodbye!",
+ bot=pytest.mail_test_bot,
+ user="mail_channel_test_user_acc"
+ )
+ docs = MailClassificationConfig.get_docs(bot=pytest.mail_test_bot)
+ assert len(docs) == 2
+ assert docs[0]["intent"] == "greeting"
+ assert docs[1]["intent"] == "goodbye"
+ MailClassificationConfig.objects.delete()
+
+
+
+ def test_get_doc(self):
+ MailClassificationConfig.create_doc(
+ intent="greeting",
+ entities=["user_name"],
+ subjects=["hello"],
+ classification_prompt="Classify this email as a greeting.",
+ reply_template="Hi, how can I help?",
+ bot=pytest.mail_test_bot,
+ user="mail_channel_test_user_acc"
+ )
+ doc = MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting")
+ assert doc["intent"] == "greeting"
+ assert doc["classification_prompt"] == "Classify this email as a greeting."
+ MailClassificationConfig.objects.delete()
+
+
+ def test_get_doc_nonexistent(self):
+ """Test retrieving a non-existent document."""
+ with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"):
+ MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting")
+
+ MailClassificationConfig.objects.delete()
+
+
+ def test_delete_doc(self):
+ """Test deleting a document."""
+ MailClassificationConfig.create_doc(
+ intent="greeting",
+ entities=["user_name"],
+ subjects=["hello"],
+ classification_prompt="Classify this email as a greeting.",
+ reply_template="Hi, how can I help?",
+ bot=pytest.mail_test_bot,
+ user="mail_channel_test_user_acc"
+ )
+ MailClassificationConfig.delete_doc(bot=pytest.mail_test_bot, intent="greeting")
+ with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"):
+ MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting")
+
+ MailClassificationConfig.objects.delete()
+
+
+ def test_soft_delete_doc(self):
+ MailClassificationConfig.create_doc(
+ intent="greeting",
+ entities=["user_name"],
+ subjects=["hello"],
+ classification_prompt="Classify this email as a greeting.",
+ reply_template="Hi, how can I help?",
+ bot=pytest.mail_test_bot,
+ user="mail_channel_test_user_acc"
+ )
+ MailClassificationConfig.soft_delete_doc(bot=pytest.mail_test_bot, intent="greeting")
+ with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"):
+ MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting")
+
+ MailClassificationConfig.objects.delete()
+
+
+
+ def test_update_doc(self):
+ MailClassificationConfig.create_doc(
+ intent="greeting",
+ entities=["user_name"],
+ subjects=["hello"],
+ classification_prompt="Classify this email as a greeting.",
+ reply_template="Hi, how can I help?",
+ bot=pytest.mail_test_bot,
+ user="mail_channel_test_user_acc"
+ )
+ MailClassificationConfig.update_doc(
+ bot=pytest.mail_test_bot,
+ intent="greeting",
+ entities=["user_name", "greeting"],
+ reply_template="Hello there!"
+ )
+ doc = MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting")
+ assert doc["entities"] == ["user_name", "greeting"]
+ assert doc["reply_template"] == "Hello there!"
+
+ MailClassificationConfig.objects.delete()
+
+ def test_update_doc_invalid_key(self):
+ MailClassificationConfig.create_doc(
+ intent="greeting",
+ entities=["user_name"],
+ subjects=["hello"],
+ classification_prompt="Classify this email as a greeting.",
+ reply_template="Hi, how can I help?",
+ bot=pytest.mail_test_bot,
+ user="mail_channel_test_user_acc"
+ )
+ with pytest.raises(AppException, match=r"Invalid key \[invalid_key\] provided for updating mail config"):
+ MailClassificationConfig.update_doc(
+ bot=pytest.mail_test_bot,
+ intent="greeting",
+ invalid_key="value"
+ )
+
+ MailClassificationConfig.objects.delete()
+
+
+ @patch("kairon.shared.channels.mail.processor.LLMProcessor")
+ @patch("kairon.shared.channels.mail.processor.MailBox")
+ @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config")
+ @patch("kairon.shared.utils.Utility.execute_http_request")
+ def test_login_imap(self, execute_http_req, mock_get_channel_config, mock_mailbox, mock_llm_processor):
+ self.create_basic_data()
+ execute_http_req.return_value = {"success": True}
+ mock_mailbox_instance = MagicMock()
+ mock_mailbox.return_value = mock_mailbox_instance
+ mock_mailbox_instance.login.return_value = ("OK", ["Logged in"])
+ mock_mailbox_instance._simple_command.return_value = ("OK", ["Logged in"])
+ mock_mailbox_instance.select.return_value = ("OK", ["INBOX"])
+
+ mock_llm_processor_instance = MagicMock()
+ mock_llm_processor.return_value = mock_llm_processor_instance
+
+ mock_get_channel_config.return_value = {
+ 'config': {
+ 'email_account': "mail_channel_test_user_acc@testuser.com",
+ 'email_password': "password",
+ 'imap_server': "imap.testuser.com"
+ }
+ }
+
+ bot_id = pytest.mail_test_bot
+ mp = MailProcessor(bot=bot_id)
+
+ mp.login_imap()
+
+ mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False)
+ mock_mailbox.assert_called_once_with("imap.testuser.com")
+ mock_mailbox_instance.login.assert_called_once_with("mail_channel_test_user_acc@testuser.com", "password")
+ mock_llm_processor.assert_called_once_with(bot_id, mp.llm_type)
+
+
+
+ @patch("kairon.shared.channels.mail.processor.LLMProcessor")
+ @patch("kairon.shared.channels.mail.processor.MailBox")
+ @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config")
+ @patch("kairon.shared.utils.Utility.execute_http_request")
+ def test_login_imap_logout(self,execute_http_request, mock_get_channel_config, mock_mailbox, mock_llm_processor):
+ self.create_basic_data()
+ execute_http_request.return_value = {"success": True}
+ mock_mailbox_instance = MagicMock()
+ mock_mailbox.return_value = mock_mailbox_instance
+ mock_mailbox_instance.login.return_value = mock_mailbox_instance # Ensure login returns the instance
+ mock_mailbox_instance._simple_command.return_value = ("OK", ["Logged in"])
+ mock_mailbox_instance.select.return_value = ("OK", ["INBOX"])
+
+ mock_llm_processor_instance = MagicMock()
+ mock_llm_processor.return_value = mock_llm_processor_instance
+
+ mock_get_channel_config.return_value = {
+ 'config': {
+ 'email_account': "mail_channel_test_user_acc@testuser.com",
+ 'email_password': "password",
+ 'imap_server': "imap.testuser.com"
+ }
+ }
+
+ bot_id = pytest.mail_test_bot
+ mp = MailProcessor(bot=bot_id)
+
+ mp.login_imap()
+ mp.logout_imap()
+
+ mock_mailbox_instance.logout.assert_called_once()
+
+
+ @patch("kairon.shared.channels.mail.processor.smtplib.SMTP")
+ @patch("kairon.shared.channels.mail.processor.LLMProcessor")
+ @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config")
+ def test_login_smtp(self, mock_get_channel_config, mock_llm_processor, mock_smtp):
+ # Arrange
+ mock_smtp_instance = MagicMock()
+ mock_smtp.return_value = mock_smtp_instance
+
+ mock_llm_processor_instance = MagicMock()
+ mock_llm_processor.return_value = mock_llm_processor_instance
+
+ mock_get_channel_config.return_value = {
+ 'config': {
+ 'email_account': "mail_channel_test_user_acc@testuser.com",
+ 'email_password': "password",
+ 'smtp_server': "smtp.testuser.com",
+ 'smtp_port': 587
+ }
+ }
+
+ bot_id = pytest.mail_test_bot
+ mp = MailProcessor(bot=bot_id)
+
+ mp.login_smtp()
+
+ mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False)
+ mock_smtp.assert_called_once_with("smtp.testuser.com", 587, timeout=30)
+ mock_smtp_instance.starttls.assert_called_once()
+ mock_smtp_instance.login.assert_called_once_with("mail_channel_test_user_acc@testuser.com", "password")
+
+
+ @patch("kairon.shared.channels.mail.processor.smtplib.SMTP")
+ @patch("kairon.shared.channels.mail.processor.LLMProcessor")
+ @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config")
+ def test_logout_smtp(self, mock_get_channel_config, mock_llm_processor, mock_smtp):
+ mock_smtp_instance = MagicMock()
+ mock_smtp.return_value = mock_smtp_instance
+
+ mock_llm_processor_instance = MagicMock()
+ mock_llm_processor.return_value = mock_llm_processor_instance
+
+ mock_get_channel_config.return_value = {
+ 'config': {
+ 'email_account': "mail_channel_test_user_acc@testuser.com",
+ 'email_password': "password",
+ 'smtp_server': "smtp.testuser.com",
+ 'smtp_port': 587
+ }
+ }
+
+ bot_id = pytest.mail_test_bot
+ mp = MailProcessor(bot=bot_id)
+
+ mp.login_smtp()
+ mp.logout_smtp()
+
+ mock_smtp_instance.quit.assert_called_once()
+ assert mp.smtp is None
+
+ @patch("kairon.shared.channels.mail.processor.smtplib.SMTP")
+ @patch("kairon.shared.channels.mail.processor.LLMProcessor")
+ @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config")
+ @pytest.mark.asyncio
+ async def test_send_mail(self, mock_get_channel_config, mock_llm_processor, mock_smtp):
+ mock_smtp_instance = MagicMock()
+ mock_smtp.return_value = mock_smtp_instance
+
+ mock_llm_processor_instance = MagicMock()
+ mock_llm_processor.return_value = mock_llm_processor_instance
+
+ mock_get_channel_config.return_value = {
+ 'config': {
+ 'email_account': "mail_channel_test_user_acc@testuser.com",
+ 'email_password': "password",
+ 'smtp_server': "smtp.testuser.com",
+ 'smtp_port': 587
+ }
+ }
+
+ bot_id = pytest.mail_test_bot
+ mp = MailProcessor(bot=bot_id)
+ mp.login_smtp()
+
+ await mp.send_mail("recipient@test.com", "Test Subject", "Test Body")
+
+ mock_smtp_instance.sendmail.assert_called_once()
+ assert mock_smtp_instance.sendmail.call_args[0][0] == "mail_channel_test_user_acc@testuser.com"
+ assert mock_smtp_instance.sendmail.call_args[0][1] == "recipient@test.com"
+ assert "Test Subject" in mock_smtp_instance.sendmail.call_args[0][2]
+ assert "Test Body" in mock_smtp_instance.sendmail.call_args[0][2]
+
+
+ @patch("kairon.shared.channels.mail.processor.MailClassificationConfig")
+ @patch("kairon.shared.channels.mail.processor.LLMProcessor")
+ @patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config")
+ def test_process_mail(self, mock_get_channel_config, llm_processor, mock_mail_classification_config):
+ mock_get_channel_config.return_value = {
+ 'config': {
+ 'email_account': "mail_channel_test_user_acc@testuser.com",
+ 'email_password': "password",
+ 'imap_server': "imap.testuser.com"
+ }
+ }
+
+ bot_id = pytest.mail_test_bot
+ mp = MailProcessor(bot=bot_id)
+ mp.mail_configs_dict = {
+ "greeting": MagicMock(reply_template="Hello {name}, {bot_response}")
+ }
+
+ rasa_chat_response = {
+ "slots": ["name: John Doe"],
+ "response": [{"text": "How can I help you today?"}]
+ }
+
+ result = mp.process_mail("greeting", rasa_chat_response)
+
+ assert result == "Hello John Doe, How can I help you today?"
+
+ rasa_chat_response = {
+ "slots": ["name: John Doe"],
+ "response": [{"text": "How can I help you today?"}]
+ }
+ mp.mail_configs_dict = {} # No template for the intent
+ result = mp.process_mail("greeting", rasa_chat_response)
+ assert result == MailConstants.DEFAULT_TEMPLATE.format(name="John Doe", bot_response="How can I help you today?")
+
+
+
+ @patch("kairon.shared.channels.mail.processor.LLMProcessor")
+ @patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config")
+ @patch("kairon.shared.channels.mail.processor.BotSettings.objects")
+ @patch("kairon.shared.channels.mail.processor.MailClassificationConfig.objects")
+ @patch("kairon.shared.channels.mail.processor.Bot.objects")
+ @pytest.mark.asyncio
+ async def test_classify_messages(self, mock_bot_objects, mock_mail_classification_config_objects,
+ mock_bot_settings_objects, mock_get_channel_config, mock_llm_processor):
+ mock_get_channel_config.return_value = {
+ 'config': {
+ 'email_account': "mail_channel_test_user_acc@testuser.com",
+ 'email_password': "password",
+ 'imap_server': "imap.testuser.com",
+ 'llm_type': "openai",
+ 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS,
+ 'system_prompt': "Test system prompt"
+ }
+ }
+
+ mock_bot_settings = MagicMock()
+ mock_bot_settings.llm_settings = {'enable_faq': True}
+ mock_bot_settings_objects.get.return_value = mock_bot_settings
+
+ mock_bot = MagicMock()
+ mock_bot_objects.get.return_value = mock_bot
+
+ mock_llm_processor_instance = MagicMock()
+ mock_llm_processor.return_value = mock_llm_processor_instance
+
+ future = asyncio.Future()
+ future.set_result({"content": '[{"intent": "greeting", "entities": {"name": "John Doe"}, "mail_id": "123", "subject": "Hello"}]'})
+ mock_llm_processor_instance.predict.return_value = future
+
+ bot_id = pytest.mail_test_bot
+ mp = MailProcessor(bot=bot_id)
+
+ messages = [{"mail_id": "123", "subject": "Hello", "body": "Hi there"}]
+
+ result = await mp.classify_messages(messages)
+
+ assert result == [{"intent": "greeting", "entities": {"name": "John Doe"}, "mail_id": "123", "subject": "Hello"}]
+ mock_llm_processor_instance.predict.assert_called_once()
+
+
+ @patch("kairon.shared.channels.mail.processor.LLMProcessor")
+ def test_get_context_prompt(self, llm_processor):
+ bot_id = pytest.mail_test_bot
+ mail_configs = [
+ {
+ 'intent': 'greeting',
+ 'entities': 'name',
+ 'subjects': 'Hello',
+ 'classification_prompt': 'If the email says hello, classify it as greeting'
+ },
+ {
+ 'intent': 'farewell',
+ 'entities': 'name',
+ 'subjects': 'Goodbye',
+ 'classification_prompt': 'If the email says goodbye, classify it as farewell'
+ }
+ ]
+
+ mp = MailProcessor(bot=bot_id)
+ mp.mail_configs = mail_configs
+
+ expected_context_prompt = (
+ "intent: greeting \n"
+ "entities: name \n"
+ "\nclassification criteria: \n"
+ "subjects: Hello \n"
+ "rule: If the email says hello, classify it as greeting \n\n\n"
+ "intent: farewell \n"
+ "entities: name \n"
+ "\nclassification criteria: \n"
+ "subjects: Goodbye \n"
+ "rule: If the email says goodbye, classify it as farewell \n\n\n"
+ )
+
+ context_prompt = mp.get_context_prompt()
+
+ assert context_prompt == expected_context_prompt
+
+
+ def test_extract_jsons_from_text(self):
+ text = '''
+ Here is some text with JSON objects.
+ {"key1": "value1", "key2": "value2"}
+ Some more text.
+ [{"key3": "value3"}, {"key4": "value4"}]
+ And some final text.
+ '''
+ expected_output = [
+ {"key1": "value1", "key2": "value2"},
+ [{"key3": "value3"}, {"key4": "value4"}]
+ ]
+
+ result = MailProcessor.extract_jsons_from_text(text)
+
+ assert result == expected_output
+
+
+
+
+
+ @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap")
+ @patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task")
+ @patch("kairon.shared.channels.mail.processor.MailBox")
+ @patch("kairon.shared.channels.mail.processor.LLMProcessor")
+ @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config")
+ @pytest.mark.asyncio
+ async def test_read_mails(self, mock_get_channel_config, mock_llm_processor,
+ mock_mailbox, mock_process_message_task,
+ mock_logout_imap):
+ bot_id = pytest.mail_test_bot
+
+ mock_get_channel_config.return_value = {
+ 'config': {
+ 'email_account': "mail_channel_test_user_acc@testuser.com",
+ 'email_password': "password",
+ 'imap_server': "imap.testuser.com",
+ 'llm_type': "openai",
+ 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS,
+ }
+ }
+
+ mock_llm_processor_instance = MagicMock()
+ mock_llm_processor.return_value = mock_llm_processor_instance
+
+
+ mock_mailbox_instance = MagicMock()
+ mock_mailbox.return_value = mock_mailbox_instance
+
+ mock_mail_message = MagicMock(spec=MailMessage)
+ mock_mail_message.subject = "Test Subject"
+ mock_mail_message.from_ = "test@example.com"
+ mock_mail_message.date = "2023-10-10"
+ mock_mail_message.text = "Test Body"
+ mock_mail_message.html = None
+
+ mock_mailbox_instance.login.return_value = mock_mailbox_instance
+ mock_mailbox_instance.fetch.return_value = [mock_mail_message]
+
+ mails, user, time_shift = MailProcessor.read_mails(bot_id)
+ print(mails)
+ assert len(mails) == 1
+ assert mails[0]["subject"] == "Test Subject"
+ assert mails[0]["mail_id"] == "test@example.com"
+ assert mails[0]["date"] == "2023-10-10"
+ assert mails[0]["body"] == "Test Body"
+ assert user == 'mail_channel_test_user_acc'
+ assert time_shift == 1200
+
+
+
+
+ @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap")
+ @patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task")
+ @patch("kairon.shared.channels.mail.processor.MailBox")
+ @patch("kairon.shared.channels.mail.processor.LLMProcessor")
+ @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config")
+ @pytest.mark.asyncio
+ async def test_read_mails_no_messages(self, mock_get_channel_config, mock_llm_processor,
+ mock_mailbox, mock_process_message_task,
+ mock_logout_imap):
+ bot_id = pytest.mail_test_bot
+
+ mock_get_channel_config.return_value = {
+ 'config': {
+ 'email_account': "mail_channel_test_user_acc@testuser.com",
+ 'email_password': "password",
+ 'imap_server': "imap.testuser.com",
+ 'llm_type': "openai",
+ 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS,
+ }
+ }
+
+ mock_llm_processor_instance = MagicMock()
+ mock_llm_processor.return_value = mock_llm_processor_instance
+
+
+ mock_mailbox_instance = MagicMock()
+ mock_mailbox.return_value = mock_mailbox_instance
+
+ mock_mailbox_instance.login.return_value = mock_mailbox_instance
+ mock_mailbox_instance.fetch.return_value = []
+
+ mails, user, time_shift = MailProcessor.read_mails(bot_id)
+ assert len(mails) == 0
+ assert user == 'mail_channel_test_user_acc'
+ assert time_shift == 1200
+
+ mock_logout_imap.assert_called_once()
+
+
+
+ @patch("kairon.shared.channels.mail.processor.LLMProcessor")
+ @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config")
+ @pytest.mark.asyncio
+ async def test_classify_messages_invalid_llm_response(self, mock_get_channel_config, mock_llm_processor):
+ mock_llm_processor_instance = MagicMock()
+ mock_llm_processor.return_value = mock_llm_processor_instance
+
+ future = asyncio.Future()
+ future.set_result({"content": 'invalid json content'})
+ mock_llm_processor_instance.predict.return_value = future
+
+ mp = MailProcessor(bot=pytest.mail_test_bot)
+ messages = [{"mail_id": "123", "subject": "Hello", "body": "Hi there"}]
+
+
+ ans = await mp.classify_messages(messages)
+ assert not ans
+
+
+ @patch("kairon.shared.channels.mail.processor.MailProcessor.classify_messages")
+ @patch("kairon.shared.channels.mail.processor.MailProcessor.login_smtp")
+ @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_smtp")
+ @patch("kairon.shared.channels.mail.processor.MailProcessor.send_mail")
+ @patch("kairon.chat.utils.ChatUtils.process_messages_via_bot")
+ @patch("kairon.shared.channels.mail.processor.LLMProcessor")
+ @pytest.mark.asyncio
+ async def test_process_messages(self, mock_llm_processor, mock_process_messages_via_bot, mock_send_mail, mock_logout_smtp, mock_login_smtp,
+ mock_classify_messages):
+
+
+ # Arrange
+ bot = pytest.mail_test_bot
+ batch = [{"mail_id": "test@example.com", "subject": "Test Subject", "date": "2023-10-10", "body": "Test Body"}]
+ mock_classify_messages.return_value = [{
+ "intent": "test_intent",
+ "entities": {"entity_name": "value"},
+ "mail_id": "test@example.com",
+ "subject": "Test Subject",
+ "name": "spandan"
+ }]
+ mock_process_messages_via_bot.return_value = [{
+ "slots": ["name: spandan"],
+ "response": [{"text": "Test Response"}]
+ }]
+
+ mock_llm_processor_instance = MagicMock()
+ mock_llm_processor.return_value = mock_llm_processor_instance
+
+ # Act
+ await MailProcessor.process_messages(bot, batch)
+
+ # Assert
+ mock_classify_messages.assert_called_once_with(batch)
+ mock_process_messages_via_bot.assert_called_once()
+ mock_login_smtp.assert_called_once()
+ mock_send_mail.assert_called_once()
+ mock_logout_smtp.assert_called_once()
+
+ @patch("kairon.shared.channels.mail.processor.MailProcessor.classify_messages")
+ @pytest.mark.asyncio
+ async def test_process_messages_exception(self, mock_classify_messages):
+ # Arrange
+ bot = "test_bot"
+ batch = [{"mail_id": "test@example.com", "subject": "Test Subject", "date": "2023-10-10", "body": "Test Body"}]
+ mock_classify_messages.side_effect = Exception("Test Exception")
+
+ # Act & Assert
+ with pytest.raises(AppException):
+ await MailProcessor.process_messages(bot, batch)
+
+ @patch('kairon.shared.channels.mail.processor.MailProcessor.__init__')
+ @patch('kairon.shared.channels.mail.processor.MailProcessor.login_smtp')
+ @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_smtp')
+ def test_validate_smpt_connection(self, mp, mock_logout_smtp, mock_login_smtp):
+ # Mock the login and logout methods to avoid actual SMTP server interaction
+ mp.return_value = None
+ mock_login_smtp.return_value = None
+ mock_logout_smtp.return_value = None
+
+ # Call the static method validate_smpt_connection
+ result = MailProcessor.validate_smpt_connection('test_bot_id')
+
+ # Assert that the method returns True
+ assert result
+
+ # Assert that login_smtp and logout_smtp were called once
+ mock_login_smtp.assert_called_once()
+ mock_logout_smtp.assert_called_once()
+
+ @patch('kairon.shared.channels.mail.processor.MailProcessor.login_smtp')
+ @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_smtp')
+ def test_validate_smpt_connection_failure(self, mock_logout_smtp, mock_login_smtp):
+ # Mock the login method to raise an exception
+ mock_login_smtp.side_effect = Exception("SMTP login failed")
+
+ # Call the static method validate_smpt_connection
+ result = MailProcessor.validate_smpt_connection('test_bot_id')
+
+ # Assert that the method returns False
+ assert not result
+
+
+
+
+
+
+
diff --git a/tests/unit_test/channels/mail_scheduler_test.py b/tests/unit_test/channels/mail_scheduler_test.py
new file mode 100644
index 000000000..656d46093
--- /dev/null
+++ b/tests/unit_test/channels/mail_scheduler_test.py
@@ -0,0 +1,124 @@
+from datetime import datetime, timedelta
+
+import pytest
+from unittest.mock import patch, MagicMock, AsyncMock
+import os
+from kairon import Utility
+from kairon.exceptions import AppException
+
+os.environ["system_file"] = "./tests/testing_data/system.yaml"
+Utility.load_environment()
+Utility.load_system_metadata()
+
+from kairon.shared.channels.mail.scheduler import MailScheduler
+
+@pytest.fixture
+def setup_environment():
+ with patch("pymongo.MongoClient") as mock_client, \
+ patch("kairon.shared.chat.data_objects.Channels.objects") as mock_channels, \
+ patch("kairon.shared.channels.mail.processor.MailProcessor.read_mails") as mock_read_mails, \
+ patch("apscheduler.schedulers.background.BackgroundScheduler", autospec=True) as mock_scheduler:
+
+ mock_client_instance = mock_client.return_value
+ mock_channels.return_value = [{'bot': 'test_bot_1'}, {'bot': 'test_bot_2'}]
+ mock_read_mails.return_value = ([], 'test@user.com', 60) # Mock responses and next_delay
+ mock_scheduler_instance = mock_scheduler.return_value
+ yield {
+ 'mock_client': mock_client_instance,
+ 'mock_channels': mock_channels,
+ 'mock_read_mails': mock_read_mails,
+ 'mock_scheduler': mock_scheduler_instance
+ }
+
+@pytest.mark.asyncio
+async def test_mail_scheduler_epoch(setup_environment):
+ # Arrange
+ mock_scheduler = setup_environment['mock_scheduler']
+ # MailScheduler.mail_queue_name = "test_queue"
+ MailScheduler.scheduler = mock_scheduler
+
+ # Act
+ MailScheduler.epoch()
+
+ # Assert
+ mock_scheduler.add_job.assert_called()
+
+def test_mail_scheduler_process_mails(setup_environment):
+ mock_read_mails = setup_environment['mock_read_mails']
+ mock_scheduler = setup_environment['mock_scheduler']
+ MailScheduler.scheduled_bots.add("test_bot_1")
+ MailScheduler.scheduler = mock_scheduler
+
+ MailScheduler.process_mails("test_bot_1", mock_scheduler)
+
+ mock_read_mails.assert_called_once_with('test_bot_1')
+ assert "test_bot_1" in MailScheduler.scheduled_bots
+
+
+@pytest.fixture
+def setup_environment2():
+ with patch("pymongo.MongoClient") as mock_client, \
+ patch("kairon.shared.chat.data_objects.Channels.objects") as mock_channels, \
+ patch("kairon.shared.channels.mail.processor.MailProcessor.read_mails", new_callable=AsyncMock) as mock_read_mails, \
+ patch("apscheduler.jobstores.mongodb.MongoDBJobStore.__init__", return_value=None) as mock_jobstore_init:
+
+ mock_client_instance = mock_client.return_value
+ mock_channels.return_value = MagicMock(values_list=MagicMock(return_value=[{'bot': 'test_bot_1'}, {'bot': 'test_bot_2'}]))
+ mock_read_mails.return_value = ([], 60)
+
+ yield {
+ 'mock_client': mock_client_instance,
+ 'mock_channels': mock_channels,
+ 'mock_read_mails': mock_read_mails,
+ 'mock_jobstore_init': mock_jobstore_init,
+ }
+
+
+@pytest.mark.asyncio
+async def test_mail_scheduler_epoch_creates_scheduler(setup_environment2):
+ with patch("apscheduler.schedulers.background.BackgroundScheduler.start", autospec=True) as mock_start, \
+ patch("apscheduler.schedulers.background.BackgroundScheduler.add_job", autospec=True) as mock_add_job:
+ MailScheduler.scheduler = None
+
+ started = MailScheduler.epoch()
+
+ assert started
+ assert MailScheduler.scheduler is not None
+ mock_start.assert_called_once()
+
+
+@patch('kairon.shared.channels.mail.scheduler.Utility.get_event_server_url')
+@patch('kairon.shared.channels.mail.scheduler.Utility.execute_http_request')
+def test_request_epoch_success(mock_execute_http_request, mock_get_event_server_url):
+ mock_get_event_server_url.return_value = "http://localhost"
+ mock_execute_http_request.return_value = {'success': True}
+
+ try:
+ MailScheduler.request_epoch()
+ except AppException:
+ pytest.fail("request_epoch() raised AppException unexpectedly!")
+
+@patch('kairon.shared.channels.mail.scheduler.Utility.get_event_server_url')
+@patch('kairon.shared.channels.mail.scheduler.Utility.execute_http_request')
+def test_request_epoch_failure(mock_execute_http_request, mock_get_event_server_url):
+ mock_get_event_server_url.return_value = "http://localhost"
+ mock_execute_http_request.return_value = {'success': False}
+
+ with pytest.raises(AppException):
+ MailScheduler.request_epoch()
+
+
+@patch("kairon.shared.channels.mail.processor.MailProcessor.read_mails")
+@patch("kairon.shared.channels.mail.scheduler.MailChannelScheduleEvent.enqueue")
+@patch("kairon.shared.channels.mail.scheduler.datetime")
+def test_read_mailbox_and_schedule_events(mock_datetime, mock_enqueue, mock_read_mails):
+ bot = "test_bot"
+ fixed_now = datetime(2024, 12, 1, 20, 41, 55, 390288)
+ mock_datetime.now.return_value = fixed_now
+ mock_read_mails.return_value = ([
+ {"subject": "Test Subject", "mail_id": "test@example.com", "date": "2023-10-10", "body": "Test Body"}
+ ], "mail_channel_test_user_acc", 1200)
+ next_timestamp = MailScheduler.read_mailbox_and_schedule_events(bot)
+ mock_read_mails.assert_called_once_with(bot)
+ mock_enqueue.assert_called_once()
+ assert next_timestamp == fixed_now + timedelta(seconds=1200)
\ No newline at end of file
diff --git a/tests/unit_test/chat/chat_test.py b/tests/unit_test/chat/chat_test.py
index 36f5b1000..94451e754 100644
--- a/tests/unit_test/chat/chat_test.py
+++ b/tests/unit_test/chat/chat_test.py
@@ -1,4 +1,5 @@
import time
+from unittest.mock import MagicMock, patch, AsyncMock
import ujson as json
import os
@@ -933,4 +934,33 @@ async def test_mongotracker_save(self):
data = list(store.client.get_database(config['db']).get_collection(bot).find({'type': 'flattened'}))
assert len(data) == 1
assert data[0]['tag'] == 'tracker_store'
- assert data[0]['type'] == 'flattened'
\ No newline at end of file
+ assert data[0]['type'] == 'flattened'
+
+
+
+
+@pytest.mark.asyncio
+@patch("kairon.chat.utils.AgentProcessor.get_agent_without_cache")
+@patch("kairon.chat.utils.ChatUtils.get_metadata")
+async def test_process_messages_via_bot(mock_get_metadata, mock_get_agent_without_cache):
+ messages = ["/greet", "/bye"]
+ account = 1
+ bot = "test_bot"
+ user = "test_user"
+ is_integration_user = False
+ metadata = {"key": "value"}
+
+ mock_get_metadata.return_value = metadata
+ mock_model = MagicMock()
+ mock_get_agent_without_cache.return_value = mock_model
+ mock_model.handle_message = AsyncMock(side_effect=[{"text": "Hello"}, {"text": "Goodbye"}])
+ from kairon.chat.utils import ChatUtils
+
+ responses = await ChatUtils.process_messages_via_bot(messages, account, bot, user, is_integration_user, metadata)
+
+ assert len(responses) == 2
+ assert responses[0] == {"text": "Hello"}
+ assert responses[1] == {"text": "Goodbye"}
+ mock_get_metadata.assert_called_once_with(account, bot, is_integration_user, metadata)
+ mock_get_agent_without_cache.assert_called_once_with(bot, False)
+ assert mock_model.handle_message.call_count == 2
diff --git a/tests/unit_test/cli_test.py b/tests/unit_test/cli_test.py
index b5c4ad361..5cf37b51f 100644
--- a/tests/unit_test/cli_test.py
+++ b/tests/unit_test/cli_test.py
@@ -1,7 +1,9 @@
import argparse
+import json
import os
from datetime import datetime
from unittest import mock
+from unittest.mock import patch
import pytest
from mongoengine import connect
@@ -314,6 +316,39 @@ def init_connection(self):
def test_delete_logs(self, mock_args):
cli()
+class TestMailChannelCli:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def init_connection(self):
+ os.environ["system_file"] = "./tests/testing_data/system.yaml"
+ Utility.load_environment()
+ connect(**Utility.mongoengine_connection(Utility.environment['database']["url"]))
+
+ @mock.patch("kairon.cli.mail_channel.MailChannelScheduleEvent.execute")
+ def test_start_mail_channel(self, mock_execute):
+ from kairon.cli.mail_channel import process_channel_mails
+ data = [{"mail": "test_mail"}]
+ data = json.dumps(data)
+ with patch('argparse.ArgumentParser.parse_args',
+ return_value=argparse.Namespace(func=process_channel_mails,
+ bot="test_bot",
+ user="test_user", mails=data)):
+ cli()
+ mock_execute.assert_called_once()
+
+
+ @mock.patch("kairon.cli.mail_channel.MailChannelScheduleEvent.execute")
+ def test_start_mail_channel_wrong_format(self, mock_execute):
+ from kairon.cli.mail_channel import process_channel_mails
+ data = {"mail": "test_mail"}
+ data = json.dumps(data)
+ with patch('argparse.ArgumentParser.parse_args',
+ return_value=argparse.Namespace(func=process_channel_mails,
+ bot="test_bot",
+ user="test_user", mails=data)):
+ with pytest.raises(ValueError):
+ cli()
+ mock_execute.assert_not_called()
class TestMessageBroadcastCli:
diff --git a/tests/unit_test/data_processor/agent_processor_test.py b/tests/unit_test/data_processor/agent_processor_test.py
index 8d37b1541..7d53cb630 100644
--- a/tests/unit_test/data_processor/agent_processor_test.py
+++ b/tests/unit_test/data_processor/agent_processor_test.py
@@ -135,6 +135,13 @@ def test_get_agent(self):
assert len(list(ModelProcessor.get_training_history(pytest.bot))) == 1
assert not Utility.check_empty_string(model.model_ver)
+ def test_get_agent_no_cache(self):
+ model = AgentProcessor.get_agent_without_cache(pytest.bot, False)
+ assert model
+ assert len(list(ModelProcessor.get_training_history(pytest.bot))) == 1
+ assert not Utility.check_empty_string(model.model_ver)
+
+
def test_get_agent_not_cached(self):
assert AgentProcessor.get_agent(pytest.bot)
diff --git a/tests/unit_test/events/definitions_test.py b/tests/unit_test/events/definitions_test.py
index 020517f51..fccef2737 100644
--- a/tests/unit_test/events/definitions_test.py
+++ b/tests/unit_test/events/definitions_test.py
@@ -11,7 +11,6 @@
from unittest.mock import patch
from mongoengine import connect
-from augmentation.utils import WebsiteParser
from kairon import Utility
from kairon.events.definitions.data_importer import TrainingDataImporterEvent
from kairon.events.definitions.faq_importer import FaqDataImporterEvent
@@ -1199,3 +1198,91 @@ def test_delete_message_broadcast(self):
assert len(list(MessageBroadcastProcessor.list_settings(bot))) == 1
with pytest.raises(AppException, match="Notification settings not found!"):
MessageBroadcastProcessor.get_settings(setting_id, bot)
+
+
+
+ @responses.activate
+ def test_validate_mail_channel_schedule_event(self):
+ from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent
+ bot = "test_add_schedule_event"
+ user = "test_user"
+ url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False"
+ responses.add(
+ "POST", url,
+ json={"message": "test msg", "success": True, "error_code": 400, "data": None}
+ )
+ with patch('kairon.shared.channels.mail.processor.MailProcessor.__init__', return_value=None) as mp:
+ with patch('kairon.shared.channels.mail.processor.MailProcessor.login_smtp', return_value=None) as mock_login:
+ with patch('kairon.shared.channels.mail.processor.MailProcessor.logout_smtp', return_value=None) as mock_logout:
+
+ event = MailChannelScheduleEvent(bot, user)
+ status = event.validate()
+ assert status
+
+ @responses.activate
+ def test_validate_mail_channel_schedule_event_fail(self):
+ from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent
+ bot = "test_add_schedule_event"
+ user = "test_user"
+ url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False"
+ responses.add(
+ "POST", url,
+ json={"message": "test msg", "success": True, "error_code": 400, "data": None}
+ )
+ event = MailChannelScheduleEvent(bot, user)
+ status = event.validate()
+ assert not status
+
+
+
+ @responses.activate
+ def test_trigger_mail_channel_schedule_event_enqueue(self):
+ from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent
+ bot = "test_add_schedule_event"
+ user = "test_user"
+ url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False"
+ responses.add(
+ "POST", url,
+ json={"message": "test msg", "success": True, "error_code": 400, "data": None}
+ )
+ event = MailChannelScheduleEvent(bot, user)
+ try:
+ event.enqueue()
+ except AppException as e:
+ pytest.fail(f"Unexpected exception: {e}")
+
+ @responses.activate
+ def test_trigger_mail_channel_schedule_event_enqueue_exception(self):
+ from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent
+ from kairon.exceptions import AppException
+ from unittest.mock import patch
+
+ bot = "test_add_schedule_event"
+ user = "test_user"
+ url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False"
+ responses.add(
+ "POST", url,
+ json={"message": "test msg", "success": False, "error_code": 400, "data": None}
+ )
+ event = MailChannelScheduleEvent(bot, user)
+ with pytest.raises(AppException, match="Failed to trigger email_channel_scheduler event: test msg"):
+ event.enqueue()
+
+ @responses.activate
+ def test_trigger_mail_channel_schedule_event_execute(self):
+ from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent
+ try:
+ MailChannelScheduleEvent("", "").execute()
+ except AppException as e:
+ pytest.fail(f"Unexpected exception: {e}")
+
+ @responses.activate
+ def test_trigger_mail_channel_schedule_event_execute_exception(self):
+ from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent
+ from kairon.exceptions import AppException
+ from unittest.mock import patch
+
+ with patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task",
+ side_effect=Exception("Test")):
+ with pytest.raises(AppException, match="Test"):
+ MailChannelScheduleEvent("", "").execute(mails=["test@mail.com"])
\ No newline at end of file
diff --git a/tests/unit_test/utility_test.py b/tests/unit_test/utility_test.py
index 7b7bf4c47..5427328aa 100644
--- a/tests/unit_test/utility_test.py
+++ b/tests/unit_test/utility_test.py
@@ -2848,7 +2848,8 @@ def test_get_channels(self):
"messenger",
"instagram",
"whatsapp",
- "line"
+ "line",
+ "mail"
]
channels = Utility.get_channels()
assert channels == expected_channels