diff --git a/kairon/__init__.py b/kairon/__init__.py
index 7fcd49789..7f04b40b1 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_process, mail_channel_read
parser = ArgumentParser(
prog="kairon",
@@ -62,6 +63,8 @@ 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_process.add_subparser(subparsers, parents=parent_parsers)
+ mail_channel_read.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..1e1580598 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.processor import MailProcessor
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
@@ -1658,3 +1659,14 @@ 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_channel/logs", response_model=Response)
+async def get_mail_channel_logs(start_idx: int = 0, page_size: int = 10,
+ current_user: User = Security(Authentication.get_current_user_and_bot,
+ scopes=TESTER_ACCESS)):
+ """
+ Retrieves mail channel related logs for the bot.
+ """
+ data = MailProcessor.get_log(current_user.get_bot(), start_idx, page_size)
+ return Response(data=data)
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_process.py b/kairon/cli/mail_channel_process.py
new file mode 100644
index 000000000..21a734b32
--- /dev/null
+++ b/kairon/cli/mail_channel_process.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 import MailProcessEvent
+
+
+def process_channel_mails(args):
+ mails = json.loads(args.mails)
+ if not isinstance(mails, list):
+ raise ValueError("Mails should be a list")
+ MailProcessEvent(args.bot, args.user).execute(mails=mails)
+
+
+def add_subparser(subparsers: SubParsersAction, parents: List[ArgumentParser]):
+ mail_parser = subparsers.add_parser(
+ "mail-channel-process",
+ conflict_handler="resolve",
+ formatter_class=ArgumentDefaultsHelpFormatter,
+ parents=parents,
+ help="Mail channel process mails"
+ )
+ 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/cli/mail_channel_read.py b/kairon/cli/mail_channel_read.py
new file mode 100644
index 000000000..2ee345955
--- /dev/null
+++ b/kairon/cli/mail_channel_read.py
@@ -0,0 +1,28 @@
+from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
+from typing import List
+from rasa.cli import SubParsersAction
+
+from kairon.events.definitions.mail_channel import MailReadEvent
+
+
+def read_channel_mails(args):
+ MailReadEvent(args.bot, args.user).execute()
+
+
+def add_subparser(subparsers: SubParsersAction, parents: List[ArgumentParser]):
+ mail_parser = subparsers.add_parser(
+ "mail-channel-read",
+ conflict_handler="resolve",
+ formatter_class=ArgumentDefaultsHelpFormatter,
+ parents=parents,
+ help="Mail channel initiate reading"
+ )
+ 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.set_defaults(func=read_channel_mails)
\ No newline at end of file
diff --git a/kairon/events/definitions/factory.py b/kairon/events/definitions/factory.py
index 9ff4128f0..31adda787 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 import MailProcessEvent, MailReadEvent
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,9 @@ class EventFactory:
EventClass.multilingual: MultilingualEvent,
EventClass.faq_importer: FaqDataImporterEvent,
EventClass.message_broadcast: MessageBroadcastEvent,
- EventClass.content_importer: DocContentImporterEvent
+ EventClass.content_importer: DocContentImporterEvent,
+ EventClass.mail_channel_read_mails: MailReadEvent,
+ EventClass.mail_channel_process_mails: MailProcessEvent
}
@staticmethod
diff --git a/kairon/events/definitions/mail_channel.py b/kairon/events/definitions/mail_channel.py
new file mode 100644
index 000000000..61b09fd7e
--- /dev/null
+++ b/kairon/events/definitions/mail_channel.py
@@ -0,0 +1,98 @@
+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 MailProcessEvent(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_smtp_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.mail_channel_process_mails, 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)
+
+
+
+class MailReadEvent(EventsBase):
+ """
+ Event to read mails from mail channel and create events for each mail tp process them via bot
+ """
+
+ 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_imap_connection(self.bot)
+
+ def enqueue(self, **kwargs):
+ """
+ Send event to event server.
+ """
+ try:
+ payload = {'bot': self.bot, 'user': self.user}
+ Utility.request_event_server(EventClass.mail_channel_read_mails, payload)
+ except Exception as e:
+ logger.error(str(e))
+ raise AppException(e)
+
+ def execute(self, **kwargs):
+ """
+ Execute the event.
+ """
+ try:
+ vals = MailProcessor.read_mails(self.bot)
+ print(vals)
+ emails, _, _ = vals
+ for email in emails:
+ ev = MailProcessEvent(self.bot, self.user)
+ ev.validate()
+ ev.enqueue(mails=[email])
+
+ except Exception as e:
+ raise AppException(f"Failed to schedule mail reading for bot {self.bot}. Error: {str(e)}")
diff --git a/kairon/events/server.py b/kairon/events/server.py
index 344e8528f..781c5c2fc 100644
--- a/kairon/events/server.py
+++ b/kairon/events/server.py
@@ -56,6 +56,7 @@ async def lifespan(app: FastAPI):
""" MongoDB is connected on the bot trainer startup """
config: dict = Utility.mongoengine_connection(Utility.environment['database']["url"])
connect(**config)
+ EventUtility.reschedule_all_bots_channel_mail_reading()
yield
disconnect()
@@ -144,3 +145,9 @@ 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/schedule/{bot}', response_model=Response)
+def request_epoch(bot: Text = Path(description="Bot id")):
+ EventUtility.schedule_channel_mail_reading(bot)
+ return {"data": None, "message": "Mail scheduler epoch request!"}
\ No newline at end of file
diff --git a/kairon/events/utility.py b/kairon/events/utility.py
index 2d9202b36..1d4f0be24 100644
--- a/kairon/events/utility.py
+++ b/kairon/events/utility.py
@@ -1,9 +1,14 @@
from typing import Dict, Text
+from uuid6 import uuid7
+
from kairon.events.executors.factory import ExecutorFactory
from kairon.events.scheduler.kscheduler import KScheduler
from kairon.exceptions import AppException
+from kairon.shared.chat.data_objects import Channels
+from kairon.shared.constants import EventClass, ChannelTypes
from kairon.shared.data.constant import TASK_TYPE
+from loguru import logger
class EventUtility:
@@ -32,3 +37,36 @@ def update_job(event_type: Text, request_data: Dict, is_scheduled: bool):
KScheduler().update_job(event_class=event_type, event_id=event_id, task_type=TASK_TYPE.EVENT.value,
**request_data)
return None, 'Scheduled event updated!'
+
+ @staticmethod
+ def schedule_channel_mail_reading(bot: str):
+ from kairon.shared.channels.mail.processor import MailProcessor
+
+ try:
+ mail_processor = MailProcessor(bot)
+ interval = mail_processor.config.get("interval", 60)
+ event_id = mail_processor.state.event_id
+ if event_id:
+ KScheduler().update_job(event_id,
+ TASK_TYPE.EVENT,
+ f"*/{interval} * * * *",
+ EventClass.mail_channel_read_mails, {"bot": bot, "user": mail_processor.bot_settings.user})
+ else:
+ event_id = uuid7().hex
+ mail_processor.update_event_id(event_id)
+ KScheduler().add_job(event_id,
+ TASK_TYPE.EVENT,
+ f"*/{interval} * * * *",
+ EventClass.mail_channel_read_mails, {"bot": bot, "user": mail_processor.bot_settings.user})
+ except Exception as e:
+ raise AppException(f"Failed to schedule mail reading for bot {bot}. Error: {str(e)}")
+
+ @staticmethod
+ def reschedule_all_bots_channel_mail_reading():
+ try:
+ bots = list(Channels.objects(connector_type= ChannelTypes.MAIL.value).distinct("bot"))
+ for bot in bots:
+ logger.info(f"Rescheduling mail reading for bot {bot}")
+ EventUtility.schedule_channel_mail_reading(bot)
+ except Exception as e:
+ raise AppException(f"Failed to reschedule mail reading events. Error: {str(e)}")
\ 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..737448543
--- /dev/null
+++ b/kairon/shared/channels/mail/constants.py
@@ -0,0 +1,11 @@
+
+
+class MailConstants:
+ DEFAULT_SMTP_SERVER = 'smtp.gmail.com'
+ DEFAULT_IMAP_SERVER = 'imap.gmail.com'
+ DEFAULT_SMTP_PORT = 587
+
+ DEFAULT_TEMPLATE = "
{bot_response}
Generated by kAIron AI.\n"
+
+ 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..ab387e0eb
--- /dev/null
+++ b/kairon/shared/channels/mail/data_objects.py
@@ -0,0 +1,44 @@
+import time
+from enum import Enum
+
+from mongoengine import Document, StringField, ListField, FloatField, DictField, IntField
+from kairon.shared.data.audit.data_objects import Auditlog
+
+
+
+class MailChannelStateData(Document):
+ event_id = StringField()
+ last_email_uid = IntField(default=0)
+ bot = StringField(required=True)
+ timestamp = FloatField(default=time.time())
+
+ meta = {"indexes": ["bot"]}
+
+ def save(self, *args, **kwargs):
+ self.timestamp = time.time()
+ super(MailChannelStateData, self).save(*args, **kwargs)
+
+class MailStatus(Enum):
+ Processing = "processing"
+ SUCCESS = "success"
+ FAILED = "failed"
+
+class MailResponseLog(Auditlog):
+ """
+ Mail response log
+ """
+ sender_id = StringField(required=True)
+ subject = StringField()
+ body = StringField()
+ responses = ListField()
+ slots = DictField()
+ bot = StringField(required=True)
+ user = StringField(required=True)
+ timestamp = FloatField(required=True)
+ status = StringField(required=True, default=MailStatus.Processing.value)
+
+ meta = {"indexes": ["bot"]}
+
+ def save(self, *args, **kwargs):
+ self.timestamp = time.time()
+ super(MailResponseLog, self).save(*args, **kwargs)
diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py
new file mode 100644
index 000000000..f68f286bb
--- /dev/null
+++ b/kairon/shared/channels/mail/processor.py
@@ -0,0 +1,298 @@
+import asyncio
+import time
+
+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 MailResponseLog, MailStatus, MailChannelStateData
+from kairon.shared.chat.processor import ChatDataProcessor
+from kairon.shared.constants import ChannelTypes
+from kairon.shared.data.data_objects import BotSettings
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+import smtplib
+
+
+
+class MailProcessor:
+ def __init__(self, bot):
+ self.bot = bot
+ self.config = ChatDataProcessor.get_channel_config(ChannelTypes.MAIL, bot, False)['config']
+ self.intent = self.config.get('intent')
+ self.mail_template = self.config.get('mail_template', MailConstants.DEFAULT_TEMPLATE)
+ self.bot_settings = BotSettings.objects(bot=self.bot).get()
+ self.state = MailProcessor.get_mail_channel_state_data(bot)
+ bot_info = Bot.objects.get(id=bot)
+ self.account = bot_info.account
+ self.mailbox = None
+ self.smtp = None
+
+ def update_event_id(self, event_id):
+ self.state.event_id = event_id
+ self.state.save()
+
+ @staticmethod
+ def get_mail_channel_state_data(bot):
+ """
+ Get mail channel state data
+ """
+ try:
+ state = MailChannelStateData.objects(bot=bot).first()
+ if not state:
+ state = MailChannelStateData(bot=bot)
+ state.save()
+ return state
+ except Exception as e:
+ raise AppException(str(e))
+
+ 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_smtp_connection(bot):
+ try:
+ mp = MailProcessor(bot)
+ mp.login_smtp()
+ mp.logout_smtp()
+ return True
+ except Exception as e:
+ logger.error(str(e))
+ return False
+
+ @staticmethod
+ def validate_imap_connection(bot):
+ try:
+ mp = MailProcessor(bot)
+ mp.login_imap()
+ mp.logout_imap()
+ return True
+ except Exception as e:
+ logger.error(str(e))
+ return False
+
+ async def send_mail(self, to: str, subject: str, body: str, log_id: str):
+ exception = None
+ try:
+ if body and len(body) > 0:
+ 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)}")
+ exception = str(e)
+ finally:
+ mail_log = MailResponseLog.objects.get(id=log_id)
+ mail_log.status = MailStatus.FAILED.value if exception else MailStatus.SUCCESS.value
+ if exception:
+ mail_log.responses.append(exception)
+ mail_log.save()
+
+ def process_mail(self, rasa_chat_response: dict, log_id: str):
+ 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', []))
+ if len(responses) == 0:
+ return ''
+ slots['bot_response'] = responses
+ mail_template = self.mail_template
+ mail_log = MailResponseLog.objects.get(id=log_id)
+ mail_log.responses = rasa_chat_response.get('response', [])
+ mail_log.slots = slots
+ mail_log.save()
+ return mail_template.format(**{key: str(value) for key, value in slots.items()})
+
+
+ @staticmethod
+ def get_log(bot_id: str, offset: int, limit: int) -> dict:
+ """
+ Get logs for a bot
+ """
+ try:
+ count = MailResponseLog.objects(bot=bot_id).count()
+ logs = MailResponseLog.objects(bot=bot_id).order_by('-timestamp').skip(offset).limit(limit)
+ result = []
+ for log in logs:
+ log = log.to_mongo().to_dict()
+ log.pop('_id')
+ log.pop('bot')
+ log.pop('user')
+ result.append(log)
+ return {
+ "logs": result,
+ "count": count
+ }
+ except Exception as e:
+ raise AppException(str(e))
+
+ @staticmethod
+ async def process_messages(bot: str, batch: [dict]):
+ """
+ Pass messages to bot and send responses
+ """
+ try:
+ from kairon.chat.utils import ChatUtils
+ mp = MailProcessor(bot)
+ user_messages: [str] = []
+ responses = []
+ for mail in batch:
+ try:
+ entities = {
+ 'mail_id': mail['mail_id'],
+ 'subject': mail['subject'],
+ 'date': mail['date'],
+ 'body': mail['body']
+ }
+ entities_str = ', '.join([f'"{key}": "{value}"' for key, value in entities.items() if value and value != 'null'])
+ user_msg = f'/{mp.intent}{{{entities_str}}}'
+ user_messages.append(user_msg)
+ subject = mail.get('subject', 'Reply')
+ if not subject.startswith('Re:'):
+ subject = f"Re: {subject}"
+
+ responses.append({
+ 'to': mail['mail_id'],
+ 'subject': subject,
+ 'body': '',
+ 'log_id': mail['log_id']
+ })
+ except Exception as e:
+ logger.error(str(e))
+
+ chat_responses = await ChatUtils.process_messages_via_bot(user_messages,
+ mp.account,
+ bot,
+ mp.bot_settings.user,
+ False,
+ {
+ 'channel': ChannelTypes.MAIL.value
+ })
+
+ for index, response in enumerate(chat_responses):
+ responses[index]['body'] = mp.process_mail(response, log_id=batch[index]['log_id'])
+
+ 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))
+
+
+ @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
+ - log_id
+ - user
+ - time_shift
+
+ """
+ logger.info(f"reading mails for {bot}")
+ 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
+ last_processed_uid = mp.state.last_email_uid
+ query = f'{int(last_processed_uid) + 1}:*'
+ try:
+ mp.login_imap()
+ is_logged_in = True
+ msgs = mp.mailbox.fetch(AND(date_gte=last_read_timestamp.date(), uid=query), mark_seen=False)
+ for msg in msgs:
+ if int(msg.uid) <= last_processed_uid:
+ continue
+ last_processed_uid = int(msg.uid)
+ subject = msg.subject
+ sender_id = msg.from_
+ date = msg.date
+ body = msg.text or msg.html or ""
+ #attachments = msg.attachments
+ mail_log = MailResponseLog(sender_id = sender_id,
+ subject = subject,
+ body = body,
+ bot = bot,
+ user = mp.bot_settings.user,
+ status=MailStatus.Processing.value,
+ timestamp = time.time())
+ mail_log.save()
+ message_entry = {
+ 'mail_id': sender_id,
+ 'subject': subject,
+ 'date': str(date),
+ 'body': body,
+ 'log_id': str(mail_log.id)
+ }
+ messages.append(message_entry)
+ mp.logout_imap()
+
+ mp.state.last_email_uid = last_processed_uid
+ mp.state.save()
+
+ 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
+
+
diff --git a/kairon/shared/channels/mail/scheduler.py b/kairon/shared/channels/mail/scheduler.py
new file mode 100644
index 000000000..adff58319
--- /dev/null
+++ b/kairon/shared/channels/mail/scheduler.py
@@ -0,0 +1,38 @@
+from urllib.parse import urljoin
+
+
+from kairon import Utility
+from kairon.exceptions import AppException
+from kairon.shared.channels.mail.processor import MailProcessor
+
+
+class MailScheduler:
+
+ @staticmethod
+ def request_epoch(bot: str):
+ if not MailProcessor.validate_smtp_connection(bot):
+ raise AppException("Failed to validate smtp connection, please revise mail channel configuration")
+
+ if not MailProcessor.validate_imap_connection(bot):
+ raise AppException("Failed to validate imap connection, please revise mail channel configuration")
+
+ event_server_url = Utility.get_event_server_url()
+ resp = Utility.execute_http_request(
+ "GET",
+ urljoin(
+ event_server_url,
+ f"/api/mail/schedule/{bot}",
+ ),
+ err_msg="Failed to request epoch",
+ )
+ if not resp['success']:
+ raise AppException("Failed to request email channel epoch")
+
+
+
+
+
+
+
+
+
diff --git a/kairon/shared/chat/processor.py b/kairon/shared/chat/processor.py
index 43d136e50..77fc10500 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(bot)
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..b4151ea8d 100644
--- a/kairon/shared/constants.py
+++ b/kairon/shared/constants.py
@@ -80,6 +80,8 @@ class EventClass(str, Enum):
web_search = "web_search"
scheduler_evaluator = "scheduler_evaluator"
content_importer = "content_importer"
+ mail_channel_process_mails = "email_channel_process_mails"
+ mail_channel_read_mails = "email_channel_read_mails"
class EventRequestType(str, Enum):
@@ -115,7 +117,7 @@ class ChannelTypes(str, Enum):
INSTAGRAM = "instagram"
BUSINESS_MESSAGES = "business_messages"
LINE = "line"
-
+ MAIL = "mail"
class ElementTypes(str, Enum):
LINK = "link"
@@ -156,6 +158,10 @@ class KaironSystemSlots(str, Enum):
flow_reply = "flow_reply"
quick_reply = "quick_reply"
http_status_code = "http_status_code"
+ mail_id = "mail_id"
+ subject = "subject"
+ body = "body"
+
class VectorEmbeddingsDatabases(str, Enum):
diff --git a/kairon/shared/data/data_models.py b/kairon/shared/data/data_models.py
index 346e0707f..8be28e7e7 100644
--- a/kairon/shared/data/data_models.py
+++ b/kairon/shared/data/data_models.py
@@ -1341,3 +1341,4 @@ def validate_name(cls, values):
raise ValueError("Schedule action can not be empty, it is needed to execute on schedule time")
return values
+
diff --git a/metadata/integrations.yml b/metadata/integrations.yml
index c767e5c18..d433d18f4 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
+ - intent
actions:
pipedrive:
diff --git a/requirements/prod.txt b/requirements/prod.txt
index 504571aad..6dec99fc5 100644
--- a/requirements/prod.txt
+++ b/requirements/prod.txt
@@ -67,5 +67,6 @@ mongoengine-jsonschema==0.1.3
fernet==1.0.1
google-generativeai
huggingface-hub==0.25.2
+imap-tools==1.7.4
more-itertools
python-multipart>=0.0.18
\ No newline at end of file
diff --git a/system.yaml b/system.yaml
index eec7ce823..3f4596832 100644
--- a/system.yaml
+++ b/system.yaml
@@ -153,7 +153,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..0de5a2fde 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,6 +11755,7 @@ 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):
diff --git a/tests/integration_test/event_service_test.py b/tests/integration_test/event_service_test.py
index 46135d175..bfe20d5cc 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.events.utility.EventUtility.schedule_channel_mail_reading')
+def test_request_epoch(mock_epoch):
+ response = client.get('/api/mail/schedule/test_bot')
+ 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 f9be29a12..bdf533494 100644
--- a/tests/integration_test/services_test.py
+++ b/tests/integration_test/services_test.py
@@ -4406,6 +4406,8 @@ def test_get_live_agent_after_disabled():
assert actual["success"]
+
+
def test_callback_config_add_syntax_error():
request_body = {
"name": "callback_1",
@@ -4746,6 +4748,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 = {
@@ -8295,7 +8298,7 @@ def test_list_entities_empty():
)
actual = response.json()
assert actual["error_code"] == 0
- assert len(actual['data']) == 14
+ assert len(actual['data']) == 17
assert actual["success"]
@@ -9062,7 +9065,8 @@ def test_list_entities():
expected = {'bot', 'file', 'category', 'file_text', 'ticketid', 'file_error',
'priority', 'requested_slot', 'fdresponse', 'kairon_action_response',
'audio', 'image', 'doc_url', 'document', 'video', 'order', 'payment', 'latitude',
- 'longitude', 'flow_reply', 'http_status_code', 'name', 'quick_reply'}
+ 'longitude', 'flow_reply', 'http_status_code', 'name', 'quick_reply', 'mail_id',
+ 'subject', 'body'}
assert not DeepDiff({item['name'] for item in actual['data']}, expected, ignore_order=True)
assert actual["success"]
@@ -9700,12 +9704,12 @@ def test_get_slots():
)
actual = response.json()
assert "data" in actual
- assert len(actual["data"]) == 21
+ assert len(actual["data"]) == 24
assert actual["success"]
assert actual["error_code"] == 0
assert Utility.check_empty_string(actual["message"])
default_slots_count = sum(slot.get('is_default') for slot in actual["data"])
- assert default_slots_count == 14
+ assert default_slots_count == 17
def test_add_slots():
@@ -23862,6 +23866,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 25ad50573..5baaf8daf 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..c4cd2f127
--- /dev/null
+++ b/tests/unit_test/channels/mail_channel_test.py
@@ -0,0 +1,547 @@
+import asyncio
+import os
+from unittest.mock import patch, MagicMock
+
+import pytest
+from imap_tools import MailMessage
+
+from mongoengine import connect, disconnect
+from uuid6 import uuid7
+
+from kairon import Utility
+from kairon.shared.channels.mail.data_objects import MailResponseLog, MailChannelStateData, MailStatus
+
+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.data.data_objects import BotSettings
+
+from kairon.exceptions import AppException
+from kairon.shared.constants import ChannelTypes
+
+
+bot_data_created = False
+
+
+class TestMailChannel:
+ @pytest.fixture(autouse=True, scope='class')
+ def setup(self):
+ global bot_data_created
+ connect(**Utility.mongoengine_connection(Utility.environment['database']["url"]))
+ 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)
+ BotSettings(bot=pytest.mail_test_bot, user="mail_channel_test_user_acc").save()
+ yield
+
+ 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()
+
+
+ disconnect()
+
+
+
+
+ @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):
+ 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_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")
+
+
+
+ @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):
+ 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_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.chat.processor.ChatDataProcessor.get_channel_config")
+ def test_login_smtp(self, mock_get_channel_config, mock_smtp):
+ # Arrange
+ mock_smtp_instance = MagicMock()
+ mock_smtp.return_value = mock_smtp_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.chat.processor.ChatDataProcessor.get_channel_config")
+ def test_logout_smtp(self, mock_get_channel_config, mock_smtp):
+ mock_smtp_instance = MagicMock()
+ mock_smtp.return_value = mock_smtp_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.chat.processor.ChatDataProcessor.get_channel_config")
+ @pytest.mark.asyncio
+ async def test_send_mail(self, mock_get_channel_config, mock_smtp):
+ mock_smtp_instance = MagicMock()
+ mock_smtp.return_value = mock_smtp_instance
+
+ mail_response_log = MailResponseLog(bot=pytest.mail_test_bot,
+ sender_id="recipient@test.com",
+ user="mail_channel_test_user_acc",
+ subject="Test Subject",
+ body="Test Body",
+ )
+ mail_response_log.save()
+ mail_response_log.save()
+
+ 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", mail_response_log.id)
+
+ MailResponseLog.objects().delete()
+
+ 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.smtplib.SMTP")
+ @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config")
+ @pytest.mark.asyncio
+ async def test_send_mail_exception(self, mock_get_channel_config, mock_smtp):
+ mock_smtp_instance = MagicMock()
+ mock_smtp.return_value = mock_smtp_instance
+
+ mail_response_log = MailResponseLog(bot=pytest.mail_test_bot,
+ sender_id="recipient@test.com",
+ user="mail_channel_test_user_acc",
+ subject="Test Subject",
+ body="Test Body",
+ )
+ mail_response_log.save()
+
+ 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_smtp_instance.sendmail.side_effect = Exception("SMTP error")
+
+ await mp.send_mail("recipient@test.com", "Test Subject", "Test Body", mail_response_log.id)
+
+ log = MailResponseLog.objects.get(id=mail_response_log.id)
+ print(log.to_mongo())
+ assert log.status == MailStatus.FAILED.value
+ assert log.responses == ['SMTP error']
+ MailResponseLog.objects().delete()
+
+
+
+ @patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config")
+ def test_process_mail(self, mock_get_channel_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"
+ }
+ }
+
+ mail_response_log = MailResponseLog(bot=pytest.mail_test_bot,
+ sender_id="recipient@test.com",
+ user="mail_channel_test_user_acc",
+ subject="Test Subject",
+ body="Test Body",
+ )
+ mail_response_log.save()
+
+ bot_id = pytest.mail_test_bot
+ mp = MailProcessor(bot=bot_id)
+
+ rasa_chat_response = {
+ "slots": ["name: John Doe"],
+ "response": [{"text": "How can I help you today?"}]
+ }
+ result = mp.process_mail( rasa_chat_response, mail_response_log.id)
+ assert result == MailConstants.DEFAULT_TEMPLATE.format(bot_response="How can I help you today?")
+
+
+ rasa_chat_response = {
+ "slots": ["name: John Doe"],
+ "response": [{"text": "How can I help you today?"}]
+ }
+ mp.mail_template = "Hello {name}, {bot_response}"
+ result = mp.process_mail(rasa_chat_response, mail_response_log.id)
+ MailResponseLog.objects().delete()
+ assert result == "Hello John Doe, How can I help you today?"
+
+
+ @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.chat.processor.ChatDataProcessor.get_channel_config")
+ @pytest.mark.asyncio
+ async def test_read_mails(self, mock_get_channel_config,
+ 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",
+ }
+ }
+
+
+ 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.chat.processor.ChatDataProcessor.get_channel_config")
+ @pytest.mark.asyncio
+ async def test_read_mails_no_messages(self, mock_get_channel_config,
+ 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",
+ }
+ }
+
+
+ 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.chat.processor.ChatDataProcessor.get_channel_config")
+ @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")
+ @pytest.mark.asyncio
+ async def test_process_messages(self, mock_process_messages_via_bot, mock_send_mail, mock_logout_smtp, mock_login_smtp, mock_get_channel_config):
+
+ mail_response_log = MailResponseLog(bot=pytest.mail_test_bot,
+ sender_id="recipient@test.com",
+ user="mail_channel_test_user_acc",
+ subject="Test Subject",
+ body="Test Body",
+ )
+ mail_response_log.save()
+
+
+ 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 = pytest.mail_test_bot
+ batch = [{"mail_id": "test@example.com", "subject": "Test Subject", "date": "2023-10-10", "body": "Test Body", "log_id": str(mail_response_log.id)}]
+
+ mock_process_messages_via_bot.return_value = [{
+ "slots": ["name: spandan"],
+ "response": [{"text": "Test Response"}]
+ }]
+
+ await MailProcessor.process_messages(bot, batch)
+
+ # Assert
+ 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()
+ MailResponseLog.objects().delete()
+
+
+ @patch("kairon.shared.channels.mail.processor.MailProcessor.login_smtp")
+ @pytest.mark.asyncio
+ async def test_process_messages_exception(self, mock_exc):
+ # Arrange
+ bot = "test_bot"
+ batch = [{"mail_id": "test@example.com", "subject": "Test Subject", "date": "2023-10-10", "body": "Test Body"}]
+ mock_exc.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):
+ mp.return_value = None
+ mock_login_smtp.return_value = None
+ mock_logout_smtp.return_value = None
+
+ result = MailProcessor.validate_smtp_connection('test_bot_id')
+
+ assert result
+
+ 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_login_smtp.side_effect = Exception("SMTP login failed")
+
+ result = MailProcessor.validate_smtp_connection('test_bot_id')
+
+ assert not result
+
+ @patch('kairon.shared.channels.mail.processor.MailProcessor.__init__')
+ @patch('kairon.shared.channels.mail.processor.MailProcessor.login_imap')
+ @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_imap')
+ def test_validate_imap_connection(self, mp, mock_logout_imap, mock_login_imap):
+ mp.return_value = None
+ mock_login_imap.return_value = None
+ mock_logout_imap.return_value = None
+
+ result = MailProcessor.validate_imap_connection('test_bot_id')
+
+ assert result
+
+ mock_login_imap.assert_called_once()
+ mock_logout_imap.assert_called_once()
+
+ @patch('kairon.shared.channels.mail.processor.MailProcessor.login_imap')
+ @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_imap')
+ def test_validate_imap_connection_failure(self, mock_logout_imap, mock_login_imap):
+ mock_login_imap.side_effect = Exception("imap login failed")
+
+ result = MailProcessor.validate_imap_connection('test_bot_id')
+
+ assert not result
+
+ def test_get_mail_channel_state_data_existing_state(self):
+ bot_id = pytest.mail_test_bot
+ mock_state = MagicMock()
+
+ with patch.object(MailChannelStateData, 'objects') as mock_objects:
+ mock_objects.return_value.first.return_value = mock_state
+ result = MailProcessor.get_mail_channel_state_data(bot_id)
+
+ assert result == mock_state
+ mock_objects.return_value.first.assert_called_once()
+
+ def test_get_mail_channel_state_data_new_state(self):
+ bot_id = pytest.mail_test_bot
+ mock_state = MagicMock()
+ mock_state.bot = bot_id
+ mock_state.state = "some_state"
+ mock_state.timestamp = "some_timestamp"
+
+ with patch.object(MailChannelStateData, 'objects') as mock_objects:
+ mock_objects.return_value.first.return_value = None
+ with patch.object(MailChannelStateData, 'save', return_value=None) as mock_save:
+ with patch('kairon.shared.channels.mail.data_objects.MailChannelStateData', return_value=mock_state):
+ result = MailProcessor.get_mail_channel_state_data(bot_id)
+
+ assert result.bot == mock_state.bot
+
+
+ def test_get_mail_channel_state_data_exception(self):
+ bot_id = "test_bot"
+
+ with patch.object(MailChannelStateData, 'objects') as mock_objects:
+ mock_objects.side_effect = Exception("Test Exception")
+ with pytest.raises(AppException) as excinfo:
+ MailProcessor.get_mail_channel_state_data(bot_id)
+
+ assert str(excinfo.value) == "Test Exception"
+
+
+ def test_get_log(self):
+ bot_id = "test_bot"
+ offset = 0
+ limit = 10
+
+ mock_log = MagicMock()
+ mock_log.to_mongo.return_value.to_dict.return_value = {
+ '_id': 'some_id',
+ 'bot': bot_id,
+ 'user': 'test_user',
+ 'timestamp': 1234567890,
+ 'subject': 'Test Subject',
+ 'body': 'Test Body',
+ 'status': 'SUCCESS'
+ }
+
+ with patch.object(MailResponseLog, 'objects') as mock_objects:
+ mock_objects.return_value.count.return_value = 1
+ mock_objects.return_value.order_by.return_value.skip.return_value.limit.return_value = [mock_log]
+
+ result = MailProcessor.get_log(bot_id, offset, limit)
+
+ assert result['count'] == 1
+ assert len(result['logs']) == 1
+ assert result['logs'][0]['timestamp'] == 1234567890
+ assert result['logs'][0]['subject'] == 'Test Subject'
+ assert result['logs'][0]['body'] == 'Test Body'
+ assert result['logs'][0]['status'] == 'SUCCESS'
+
+ def test_get_log_exception(self):
+ bot_id = "test_bot"
+ offset = 0
+ limit = 10
+
+ with patch.object(MailResponseLog, 'objects') as mock_objects:
+ mock_objects.side_effect = Exception("Test Exception")
+
+ with pytest.raises(AppException) as excinfo:
+ MailProcessor.get_log(bot_id, offset, limit)
+
+ assert str(excinfo.value) == "Test Exception"
+
+ 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()
\ No newline at end of file
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..8df57f86d
--- /dev/null
+++ b/tests/unit_test/channels/mail_scheduler_test.py
@@ -0,0 +1,139 @@
+
+import pytest
+from unittest.mock import patch
+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
+ }
+
+
+@patch('kairon.shared.channels.mail.processor.MailProcessor.validate_smtp_connection')
+@patch('kairon.shared.channels.mail.processor.MailProcessor.validate_imap_connection')
+@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_imp, mock_smpt):
+ mock_get_event_server_url.return_value = "http://localhost"
+ mock_execute_http_request.return_value = {'success': True}
+ bot = "test_bot"
+ try:
+ MailScheduler.request_epoch(bot)
+ except AppException:
+ pytest.fail("request_epoch() raised AppException unexpectedly!")
+
+@patch('kairon.shared.channels.mail.processor.MailProcessor.validate_smtp_connection')
+@patch('kairon.shared.channels.mail.processor.MailProcessor.validate_imap_connection')
+@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__response_not_success(mock_execute_http_request, mock_get_event_server_url, mock_imp, mock_smpt):
+ mock_get_event_server_url.return_value = "http://localhost"
+ mock_execute_http_request.return_value = {'success': False}
+ bot = "test_bot"
+ with pytest.raises(AppException):
+ MailScheduler.request_epoch(bot)
+
+
+@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("test_bot")
+
+
+
+@patch('kairon.events.utility.KScheduler.add_job')
+@patch('kairon.events.utility.KScheduler.update_job')
+@patch('kairon.events.utility.KScheduler.__init__', return_value=None)
+@patch('kairon.shared.channels.mail.processor.MailProcessor')
+@patch('pymongo.MongoClient', autospec=True)
+def test_schedule_channel_mail_reading(mock_mongo, mock_mail_processor, mock_kscheduler, mock_update_job, mock_add_job):
+ from kairon.events.utility import EventUtility
+
+ bot = "test_bot"
+ mock_mail_processor_instance = mock_mail_processor.return_value
+ mock_mail_processor_instance.config = {"interval": 1}
+ mock_mail_processor_instance.state.event_id = None
+ mock_mail_processor_instance.bot_settings.user = "test_user"
+
+# # Test case when event_id is None
+ EventUtility.schedule_channel_mail_reading(bot)
+ mock_add_job.assert_called_once()
+ mock_update_job.assert_not_called()
+
+ mock_add_job.reset_mock()
+ mock_update_job.reset_mock()
+ mock_mail_processor_instance.state.event_id = "existing_event_id"
+
+ # Test case when event_id exists
+ EventUtility.schedule_channel_mail_reading(bot)
+ mock_update_job.assert_called_once()
+ mock_add_job.assert_not_called()
+
+@patch('kairon.events.utility.KScheduler.add_job')
+@patch('kairon.events.utility.KScheduler', autospec=True)
+@patch('kairon.shared.channels.mail.processor.MailProcessor')
+@patch('pymongo.MongoClient', autospec=True)
+def test_schedule_channel_mail_reading_exception(mock_mongo_client, mock_mail_processor, mock_kscheduler, mock_add_job):
+ from kairon.events.utility import EventUtility
+
+ bot = "test_bot"
+ mock_mail_processor.side_effect = Exception("Test Exception")
+
+ with pytest.raises(AppException) as excinfo:
+ EventUtility.schedule_channel_mail_reading(bot)
+ assert str(excinfo.value) == f"Failed to schedule mail reading for bot {bot}. Error: Test Exception"
+
+
+@patch('kairon.events.utility.EventUtility.schedule_channel_mail_reading')
+@patch('kairon.shared.chat.data_objects.Channels.objects')
+def test_reschedule_all_bots_channel_mail_reading(mock_channels_objects, mock_schedule_channel_mail_reading):
+ from kairon.events.utility import EventUtility
+
+ mock_channels_objects.return_value.distinct.return_value = ['bot1', 'bot2']
+
+ EventUtility.reschedule_all_bots_channel_mail_reading()
+
+ mock_channels_objects.return_value.distinct.assert_called_once_with("bot")
+ assert mock_schedule_channel_mail_reading.call_count == 2
+ mock_schedule_channel_mail_reading.assert_any_call('bot1')
+ mock_schedule_channel_mail_reading.assert_any_call('bot2')
+
+@patch('kairon.events.utility.EventUtility.schedule_channel_mail_reading')
+@patch('kairon.shared.chat.data_objects.Channels.objects')
+def test_reschedule_all_bots_channel_mail_reading_exception(mock_channels_objects, mock_schedule_channel_mail_reading):
+ from kairon.events.utility import EventUtility
+
+ mock_channels_objects.return_value.distinct.return_value = ['bot1', 'bot2']
+ mock_schedule_channel_mail_reading.side_effect = Exception("Test Exception")
+
+ with pytest.raises(AppException) as excinfo:
+ EventUtility.reschedule_all_bots_channel_mail_reading()
+
+ assert str(excinfo.value) == "Failed to reschedule mail reading events. Error: Test Exception"
+ mock_channels_objects.return_value.distinct.assert_called_once_with("bot")
+ assert mock_schedule_channel_mail_reading.call_count == 1
\ 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..3dce1d99f 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,49 @@ 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_process.MailProcessEvent.execute")
+ def test_start_mail_channel_process(self, mock_execute):
+ from kairon.cli.mail_channel_process 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_process.MailProcessEvent.execute")
+ def test_start_mail_channel_process_wrong_format(self, mock_execute):
+ from kairon.cli.mail_channel_process 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()
+
+ @mock.patch("kairon.cli.mail_channel_read.MailReadEvent.execute")
+ def test_start_mail_channel_read(self, mock_execute):
+ from kairon.cli.mail_channel_read import read_channel_mails
+ with patch('argparse.ArgumentParser.parse_args',
+ return_value=argparse.Namespace(func=read_channel_mails,
+ bot="test_bot",
+ user="test_user")):
+ cli()
+ mock_execute.assert_called_once()
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/data_processor/data_processor_test.py b/tests/unit_test/data_processor/data_processor_test.py
index 22c2846ec..0cefe9571 100644
--- a/tests/unit_test/data_processor/data_processor_test.py
+++ b/tests/unit_test/data_processor/data_processor_test.py
@@ -1350,7 +1350,7 @@ async def test_save_from_path_yml(self):
assert len(list(Intents.objects(bot="test_load_yml", user="testUser", use_entities=False))) == 5
assert len(list(Intents.objects(bot="test_load_yml", user="testUser", use_entities=True))) == 27
assert len(
- list(Slots.objects(bot="test_load_yml", user="testUser", influence_conversation=True, status=True))) == 12
+ list(Slots.objects(bot="test_load_yml", user="testUser", influence_conversation=True, status=True))) == 15
assert len(
list(Slots.objects(bot="test_load_yml", user="testUser", influence_conversation=False, status=True))) == 10
multiflow_stories = processor.load_multiflow_stories_yaml(bot='test_load_yml')
@@ -3880,13 +3880,13 @@ async def test_upload_case_insensitivity(self):
assert all(slot.name in ['user', 'location', 'email_id', 'application_name', 'bot', 'kairon_action_response',
'order', 'payment', 'http_status_code', 'image', 'audio', 'video', 'document',
'doc_url', 'longitude', 'latitude', 'flow_reply', 'quick_reply',
- 'session_started_metadata', 'requested_slot'] for slot in domain.slots)
+ 'session_started_metadata', 'requested_slot', 'mail_id', 'subject', 'body'] for slot in domain.slots)
assert not DeepDiff(list(domain.responses.keys()), ['utter_please_rephrase', 'utter_greet', 'utter_goodbye',
'utter_default'], ignore_order=True)
assert not DeepDiff(domain.entities,
['user', 'location', 'email_id', 'application_name', 'bot', 'kairon_action_response',
'order', 'payment', 'http_status_code', 'image', 'audio', 'video', 'document', 'doc_url',
- 'longitude', 'latitude', 'flow_reply', 'quick_reply'], ignore_order=True)
+ 'longitude', 'latitude', 'flow_reply', 'quick_reply', 'mail_id', 'subject', 'body'], ignore_order=True)
assert domain.forms == {'ask_user': {'required_slots': ['user', 'email_id']},
'ask_location': {'required_slots': ['location', 'application_name']}}
assert domain.user_actions == ['ACTION_GET_GOOGLE_APPLICATION', 'ACTION_GET_MICROSOFT_APPLICATION',
@@ -3985,8 +3985,8 @@ async def test_load_from_path_yml_training_files(self):
assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse'
domain = processor.load_domain("test_load_from_path_yml_training_files")
assert isinstance(domain, Domain)
- assert domain.slots.__len__() == 24
- assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12
+ assert domain.slots.__len__() == 27
+ assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15
assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12
assert domain.intent_properties.__len__() == 32
assert len([intent for intent in domain.intent_properties.keys() if
@@ -3994,7 +3994,7 @@ async def test_load_from_path_yml_training_files(self):
assert len([intent for intent in domain.intent_properties.keys() if
not domain.intent_properties.get(intent)['used_entities']]) == 5
assert domain.responses.keys().__len__() == 29
- assert domain.entities.__len__() == 24
+ assert domain.entities.__len__() == 27
assert domain.forms.__len__() == 2
assert domain.forms.__len__() == 2
assert domain.forms['ticket_attributes_form'] == {
@@ -4056,11 +4056,11 @@ async def test_load_from_path_all_scenario(self):
assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse'
domain = processor.load_domain("all")
assert isinstance(domain, Domain)
- assert domain.slots.__len__() == 23
+ assert domain.slots.__len__() == 26
assert all(slot.mappings[0]['type'] == 'from_entity' and slot.mappings[0]['entity'] == slot.name for slot in
domain.slots if slot.name not in ['requested_slot', 'session_started_metadata'])
assert domain.responses.keys().__len__() == 27
- assert domain.entities.__len__() == 23
+ assert domain.entities.__len__() == 26
assert domain.forms.__len__() == 2
assert domain.forms['ticket_attributes_form'] == {'required_slots': {}}
assert isinstance(domain.forms, dict)
@@ -4099,9 +4099,9 @@ async def test_load_from_path_all_scenario_append(self):
assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse'
domain = processor.load_domain("all")
assert isinstance(domain, Domain)
- assert domain.slots.__len__() == 23
+ assert domain.slots.__len__() == 26
assert domain.responses.keys().__len__() == 27
- assert domain.entities.__len__() == 23
+ assert domain.entities.__len__() == 26
assert domain.forms.__len__() == 2
assert isinstance(domain.forms, dict)
assert domain.user_actions.__len__() == 27
@@ -4126,10 +4126,10 @@ def test_load_domain(self):
processor = MongoProcessor()
domain = processor.load_domain("tests")
assert isinstance(domain, Domain)
- assert domain.slots.__len__() == 15
+ assert domain.slots.__len__() == 18
assert [s.name for s in domain.slots if s.name == 'kairon_action_response' and s.value is None]
assert domain.responses.keys().__len__() == 11
- assert domain.entities.__len__() == 14
+ assert domain.entities.__len__() == 17
assert domain.form_names.__len__() == 0
assert domain.user_actions.__len__() == 11
assert domain.intents.__len__() == 14
@@ -4376,7 +4376,7 @@ def test_add_training_example_with_entity(self):
)
slots = Slots.objects(bot="tests")
new_slot = slots.get(name="priority")
- assert slots.__len__() == 15
+ assert slots.__len__() == 18
assert new_slot.name == "priority"
assert new_slot.type == "text"
assert new_training_example.text == "Log a critical issue"
@@ -4409,7 +4409,7 @@ def test_get_training_examples_with_entities(self):
for value in actual
]
)
- assert slots.__len__() == 16
+ assert slots.__len__() == 19
assert new_slot.name == "ticketid"
assert new_slot.type == "text"
expected = ["hey", "hello", "hi", "good morning", "good evening", "hey there"]
@@ -4453,7 +4453,7 @@ def test_get_entities(self):
processor = MongoProcessor()
expected = ["bot", "priority", "file_text", "ticketid", 'kairon_action_response', 'image', 'video', 'audio',
'doc_url', 'document', 'order', 'payment', 'quick_reply', 'longitude', 'latitude', 'flow_reply',
- 'http_status_code']
+ 'http_status_code', 'mail_id', 'subject', 'body']
actual = processor.get_entities("tests")
assert actual.__len__() == expected.__len__()
assert all(item["name"] in expected for item in actual)
@@ -7130,8 +7130,8 @@ def _mock_bot_info(*args, **kwargs):
assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse'
domain = mongo_processor.load_domain(bot)
assert isinstance(domain, Domain)
- assert domain.slots.__len__() == 24
- assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12
+ assert domain.slots.__len__() == 27
+ assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15
assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12
assert domain.intent_properties.__len__() == 32
assert len([intent for intent in domain.intent_properties.keys() if
@@ -7139,7 +7139,7 @@ def _mock_bot_info(*args, **kwargs):
assert len([intent for intent in domain.intent_properties.keys() if
not domain.intent_properties.get(intent)['used_entities']]) == 5
assert domain.responses.keys().__len__() == 29
- assert domain.entities.__len__() == 24
+ assert domain.entities.__len__() == 27
assert domain.form_names.__len__() == 2
assert domain.user_actions.__len__() == 48
assert domain.intents.__len__() == 32
@@ -7195,9 +7195,9 @@ def _mock_bot_info(*args, **kwargs):
assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse'
domain = mongo_processor.load_domain(bot)
assert isinstance(domain, Domain)
- assert domain.slots.__len__() == 23
+ assert domain.slots.__len__() == 26
assert domain.responses.keys().__len__() == 27
- assert domain.entities.__len__() == 23
+ assert domain.entities.__len__() == 26
assert domain.form_names.__len__() == 2
assert domain.user_actions.__len__() == 27
assert domain.intents.__len__() == 29
@@ -7275,8 +7275,8 @@ def _mock_bot_info(*args, **kwargs):
assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse'
domain = mongo_processor.load_domain(bot)
assert isinstance(domain, Domain)
- assert domain.slots.__len__() == 24
- assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12
+ assert domain.slots.__len__() == 27
+ assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15
assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12
assert domain.intent_properties.__len__() == 32
assert len([intent for intent in domain.intent_properties.keys() if
@@ -7284,7 +7284,7 @@ def _mock_bot_info(*args, **kwargs):
assert len([intent for intent in domain.intent_properties.keys() if
not domain.intent_properties.get(intent)['used_entities']]) == 5
assert domain.responses.keys().__len__() == 29
- assert domain.entities.__len__() == 24
+ assert domain.entities.__len__() == 27
assert domain.form_names.__len__() == 2
assert domain.user_actions.__len__() == 48
assert domain.intents.__len__() == 32
@@ -7340,8 +7340,8 @@ def _mock_bot_info(*args, **kwargs):
assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse'
domain = mongo_processor.load_domain(bot)
assert isinstance(domain, Domain)
- assert domain.slots.__len__() == 24
- assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12
+ assert domain.slots.__len__() == 27
+ assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15
assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12
assert domain.intent_properties.__len__() == 33
assert len([intent for intent in domain.intent_properties.keys() if
@@ -7349,7 +7349,7 @@ def _mock_bot_info(*args, **kwargs):
assert len([intent for intent in domain.intent_properties.keys() if
not domain.intent_properties.get(intent)['used_entities']]) == 6
assert domain.responses.keys().__len__() == 31
- assert domain.entities.__len__() == 24
+ assert domain.entities.__len__() == 27
assert domain.form_names.__len__() == 2
assert domain.user_actions.__len__() == 50
assert domain.intents.__len__() == 33
@@ -7390,8 +7390,8 @@ def test_delete_nlu_only(self):
assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse'
domain = mongo_processor.load_domain(bot)
assert isinstance(domain, Domain)
- assert domain.slots.__len__() == 24
- assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12
+ assert domain.slots.__len__() == 27
+ assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15
assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12
assert domain.intent_properties.__len__() == 33
assert len([intent for intent in domain.intent_properties.keys() if
@@ -7399,7 +7399,7 @@ def test_delete_nlu_only(self):
assert len([intent for intent in domain.intent_properties.keys() if
not domain.intent_properties.get(intent)['used_entities']]) == 6
assert domain.responses.keys().__len__() == 31
- assert domain.entities.__len__() == 24
+ assert domain.entities.__len__() == 27
assert domain.form_names.__len__() == 2
assert domain.user_actions.__len__() == 50
assert domain.intents.__len__() == 33
@@ -7448,8 +7448,8 @@ def test_delete_stories_only(self):
assert story_graph.story_steps.__len__() == 0
domain = mongo_processor.load_domain(bot)
assert isinstance(domain, Domain)
- assert domain.slots.__len__() == 24
- assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12
+ assert domain.slots.__len__() == 27
+ assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15
assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12
assert domain.intent_properties.__len__() == 33
assert len([intent for intent in domain.intent_properties.keys() if
@@ -7457,7 +7457,7 @@ def test_delete_stories_only(self):
assert len([intent for intent in domain.intent_properties.keys() if
not domain.intent_properties.get(intent)['used_entities']]) == 6
assert domain.responses.keys().__len__() == 31
- assert domain.entities.__len__() == 24
+ assert domain.entities.__len__() == 27
assert domain.form_names.__len__() == 2
assert domain.user_actions.__len__() == 50
assert domain.intents.__len__() == 33
@@ -7493,8 +7493,8 @@ def test_delete_multiflow_stories_only(self):
assert story_graph.story_steps.__len__() == 0
domain = mongo_processor.load_domain(bot)
assert isinstance(domain, Domain)
- assert domain.slots.__len__() == 24
- assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12
+ assert domain.slots.__len__() == 27
+ assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15
assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12
assert domain.intent_properties.__len__() == 33
assert len([intent for intent in domain.intent_properties.keys() if
@@ -7502,7 +7502,7 @@ def test_delete_multiflow_stories_only(self):
assert len([intent for intent in domain.intent_properties.keys() if
not domain.intent_properties.get(intent)['used_entities']]) == 6
assert domain.responses.keys().__len__() == 31
- assert domain.entities.__len__() == 24
+ assert domain.entities.__len__() == 27
assert domain.form_names.__len__() == 2
assert domain.user_actions.__len__() == 50
assert domain.intents.__len__() == 33
@@ -7548,10 +7548,10 @@ def test_delete_config_and_actions_only(self):
assert story_graph.story_steps.__len__() == 16
domain = mongo_processor.load_domain(bot)
assert isinstance(domain, Domain)
- assert domain.slots.__len__() == 24
+ assert domain.slots.__len__() == 27
assert domain.intent_properties.__len__() == 33
assert domain.responses.keys().__len__() == 31
- assert domain.entities.__len__() == 24
+ assert domain.entities.__len__() == 27
assert domain.form_names.__len__() == 2
assert domain.user_actions.__len__() == 31
assert domain.intents.__len__() == 33
@@ -7626,10 +7626,10 @@ async def test_save_rules_and_domain_only(self, get_training_data):
assert len(rules) == 3
domain = mongo_processor.load_domain(bot)
assert isinstance(domain, Domain)
- assert domain.slots.__len__() == 24
+ assert domain.slots.__len__() == 27
assert domain.intent_properties.__len__() == 32
assert domain.responses.keys().__len__() == 27
- assert domain.entities.__len__() == 24
+ assert domain.entities.__len__() == 27
assert domain.form_names.__len__() == 2
assert domain.user_actions.__len__() == 46
assert domain.intents.__len__() == 32
@@ -9375,9 +9375,16 @@ def test_get_slot(self):
{'name': 'age', 'type': 'float', 'max_value': 1.0, 'min_value': 0.0, 'influence_conversation': True,
'_has_been_set': False, 'is_default': False},
{'name': 'occupation', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, 'is_default': False},
- {'name': 'quick_reply', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, 'is_default': True}
+ {'name': 'quick_reply', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, 'is_default': True},
+ {'name': 'mail_id', 'type': 'text', 'influence_conversation': True, '_has_been_set': False,
+ 'is_default': True},
+ {'name': 'subject', 'type': 'text', 'influence_conversation': True, '_has_been_set': False,
+ 'is_default': True},
+ {'name': 'body', 'type': 'text', 'influence_conversation': True, '_has_been_set': False,
+ 'is_default': True},
+
]
- assert len(slots) == 24
+ assert len(slots) == 27
assert not DeepDiff(slots, expected, ignore_order=True)
def test_update_slot_add_value_intent_and_not_intent(self):
diff --git a/tests/unit_test/events/definitions_test.py b/tests/unit_test/events/definitions_test.py
index 020517f51..da5d630a7 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,136 @@ 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 import MailProcessEvent
+ bot = "test_add_schedule_event"
+ user = "test_user"
+ url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_process_mails}?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 = MailProcessEvent(bot, user)
+ status = event.validate()
+ assert status
+
+ @responses.activate
+ def test_validate_mail_channel_schedule_event_fail(self):
+ from kairon.events.definitions.mail_channel import MailProcessEvent
+ bot = "test_add_schedule_event"
+ user = "test_user"
+ url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_process_mails}?is_scheduled=False"
+ responses.add(
+ "POST", url,
+ json={"message": "test msg", "success": True, "error_code": 400, "data": None}
+ )
+ event = MailProcessEvent(bot, user)
+ status = event.validate()
+ assert not status
+
+
+
+ @responses.activate
+ def test_trigger_mail_channel_process_event_enqueue(self):
+ from kairon.events.definitions.mail_channel import MailProcessEvent
+ bot = "test_add_schedule_event"
+ user = "test_user"
+ url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_process_mails}?is_scheduled=False"
+ responses.add(
+ "POST", url,
+ json={"message": "test msg", "success": True, "error_code": 400, "data": None}
+ )
+ event = MailProcessEvent(bot, user)
+ try:
+ event.enqueue()
+ except AppException as e:
+ pytest.fail(f"Unexpected exception: {e}")
+
+ @responses.activate
+ def test_trigger_mail_channel_process_event_enqueue_exception(self):
+ from kairon.events.definitions.mail_channel import MailProcessEvent
+ 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.mail_channel_process_mails}?is_scheduled=False"
+ responses.add(
+ "POST", url,
+ json={"message": "test msg", "success": False, "error_code": 400, "data": None}
+ )
+ event = MailProcessEvent(bot, user)
+ with pytest.raises(AppException, match="Failed to trigger email_channel_process_mails event: test msg"):
+ event.enqueue()
+
+
+ @responses.activate
+ def test_trigger_mail_channel_process_event_execute(self):
+ from kairon.events.definitions.mail_channel import MailProcessEvent
+ try:
+ MailProcessEvent("", "").execute()
+ except AppException as e:
+ pytest.fail(f"Unexpected exception: {e}")
+
+ @responses.activate
+ def test_trigger_mail_channel_process_event_execute_exception(self):
+ from kairon.events.definitions.mail_channel import MailProcessEvent
+ 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"):
+ MailProcessEvent("", "").execute(mails=["test@mail.com"])
+
+ @responses.activate
+ def test_mail_channel_read_event_enqueue(self):
+ from kairon.events.definitions.mail_channel import MailReadEvent
+ bot = "test_add_schedule_event"
+ user = "test_user"
+ url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_read_mails}?is_scheduled=False"
+ responses.add(
+ "POST", url,
+ json={"message": "test msg", "success": True, "error_code": 400, "data": None}
+ )
+ event = MailReadEvent(bot, user)
+ try:
+ event.enqueue()
+ except AppException as e:
+ pytest.fail(f"Unexpected exception: {e}")
+
+ @patch('kairon.shared.channels.mail.processor.MailProcessor.read_mails')
+ @patch('kairon.events.definitions.mail_channel.MailProcessEvent.enqueue')
+ @patch('kairon.events.definitions.mail_channel.MailProcessEvent.validate')
+ def test_mail_read_event_execute(self, mock_validate, mock_enqueue, mock_read_mails):
+ from kairon.events.definitions.mail_channel import MailReadEvent
+ bot = "test_add_schedule_event"
+ user = "test_user"
+ mock_read_mails.return_value = (["test@mail.com"], user, 10)
+ mock_validate.return_value = True
+
+ event = MailReadEvent(bot, user)
+ event.execute()
+
+ mock_read_mails.assert_called_once_with(bot)
+ mock_validate.assert_called_once()
+ mock_enqueue.assert_called_once_with(mails=["test@mail.com"])
+
+ def test_mail_read_event_execute_exception(self):
+ bot = "test_add_schedule_event"
+ user = "test_user"
+
+ with patch('kairon.shared.channels.mail.processor.MailProcessor.read_mails',
+ side_effect=Exception("Test error")):
+ from kairon.events.definitions.mail_channel import MailReadEvent
+ event = MailReadEvent(bot, user)
+ with pytest.raises(AppException, match=f"Failed to schedule mail reading for bot {bot}. Error: Test error"):
+ event.execute()
\ 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