iatoolkit 0.71.2__py3-none-any.whl → 0.91.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iatoolkit/__init__.py +15 -5
- iatoolkit/base_company.py +4 -58
- iatoolkit/cli_commands.py +6 -7
- iatoolkit/common/exceptions.py +1 -0
- iatoolkit/common/routes.py +12 -28
- iatoolkit/common/util.py +7 -1
- iatoolkit/company_registry.py +50 -14
- iatoolkit/{iatoolkit.py → core.py} +54 -55
- iatoolkit/infra/{mail_app.py → brevo_mail_app.py} +15 -37
- iatoolkit/infra/llm_client.py +9 -5
- iatoolkit/locales/en.yaml +10 -2
- iatoolkit/locales/es.yaml +171 -162
- iatoolkit/repositories/database_manager.py +59 -14
- iatoolkit/repositories/llm_query_repo.py +34 -22
- iatoolkit/repositories/models.py +16 -18
- iatoolkit/repositories/profile_repo.py +5 -10
- iatoolkit/repositories/vs_repo.py +9 -4
- iatoolkit/services/auth_service.py +1 -1
- iatoolkit/services/branding_service.py +1 -1
- iatoolkit/services/company_context_service.py +19 -11
- iatoolkit/services/configuration_service.py +219 -46
- iatoolkit/services/dispatcher_service.py +31 -225
- iatoolkit/services/document_service.py +10 -1
- iatoolkit/services/embedding_service.py +43 -41
- iatoolkit/services/excel_service.py +50 -2
- iatoolkit/services/history_manager_service.py +189 -0
- iatoolkit/services/jwt_service.py +1 -1
- iatoolkit/services/language_service.py +8 -2
- iatoolkit/services/license_service.py +82 -0
- iatoolkit/services/mail_service.py +171 -25
- iatoolkit/services/profile_service.py +37 -32
- iatoolkit/services/{prompt_manager_service.py → prompt_service.py} +110 -1
- iatoolkit/services/query_service.py +192 -191
- iatoolkit/services/sql_service.py +63 -12
- iatoolkit/services/tool_service.py +231 -0
- iatoolkit/services/user_feedback_service.py +18 -6
- iatoolkit/services/user_session_context_service.py +18 -0
- iatoolkit/static/images/iatoolkit_core.png +0 -0
- iatoolkit/static/images/iatoolkit_logo.png +0 -0
- iatoolkit/static/js/chat_feedback_button.js +1 -1
- iatoolkit/static/js/chat_help_content.js +4 -4
- iatoolkit/static/js/chat_main.js +17 -5
- iatoolkit/static/js/chat_onboarding_button.js +1 -1
- iatoolkit/static/styles/chat_iatoolkit.css +1 -1
- iatoolkit/static/styles/chat_public.css +28 -0
- iatoolkit/static/styles/documents.css +598 -0
- iatoolkit/static/styles/landing_page.css +223 -7
- iatoolkit/system_prompts/__init__.py +0 -0
- iatoolkit/system_prompts/query_main.prompt +2 -1
- iatoolkit/system_prompts/sql_rules.prompt +47 -12
- iatoolkit/templates/_company_header.html +30 -5
- iatoolkit/templates/_login_widget.html +3 -3
- iatoolkit/templates/chat.html +1 -1
- iatoolkit/templates/forgot_password.html +3 -2
- iatoolkit/templates/onboarding_shell.html +1 -1
- iatoolkit/templates/signup.html +3 -0
- iatoolkit/views/base_login_view.py +1 -1
- iatoolkit/views/change_password_view.py +1 -1
- iatoolkit/views/forgot_password_view.py +9 -4
- iatoolkit/views/history_api_view.py +3 -3
- iatoolkit/views/home_view.py +4 -2
- iatoolkit/views/init_context_api_view.py +1 -1
- iatoolkit/views/llmquery_api_view.py +4 -3
- iatoolkit/views/{file_store_api_view.py → load_document_api_view.py} +1 -1
- iatoolkit/views/login_view.py +17 -5
- iatoolkit/views/logout_api_view.py +10 -2
- iatoolkit/views/prompt_api_view.py +1 -1
- iatoolkit/views/root_redirect_view.py +22 -0
- iatoolkit/views/signup_view.py +12 -4
- iatoolkit/views/static_page_view.py +27 -0
- iatoolkit/views/verify_user_view.py +1 -1
- iatoolkit-0.91.1.dist-info/METADATA +268 -0
- iatoolkit-0.91.1.dist-info/RECORD +125 -0
- iatoolkit-0.91.1.dist-info/licenses/LICENSE_COMMUNITY.md +15 -0
- iatoolkit/services/history_service.py +0 -37
- iatoolkit/templates/about.html +0 -13
- iatoolkit/templates/index.html +0 -145
- iatoolkit/templates/login_simulation.html +0 -45
- iatoolkit/views/external_login_view.py +0 -73
- iatoolkit/views/index_view.py +0 -14
- iatoolkit/views/login_simulation_view.py +0 -93
- iatoolkit-0.71.2.dist-info/METADATA +0 -276
- iatoolkit-0.71.2.dist-info/RECORD +0 -122
- {iatoolkit-0.71.2.dist-info → iatoolkit-0.91.1.dist-info}/WHEEL +0 -0
- {iatoolkit-0.71.2.dist-info → iatoolkit-0.91.1.dist-info}/licenses/LICENSE +0 -0
- {iatoolkit-0.71.2.dist-info → iatoolkit-0.91.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import json
|
|
3
|
+
from typing import Dict, Any, Tuple, Optional
|
|
4
|
+
from iatoolkit.services.user_session_context_service import UserSessionContextService
|
|
5
|
+
from iatoolkit.services.i18n_service import I18nService
|
|
6
|
+
from iatoolkit.infra.llm_client import llmClient
|
|
7
|
+
from iatoolkit.repositories.models import Company
|
|
8
|
+
from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
|
|
9
|
+
from iatoolkit.repositories.profile_repo import ProfileRepo
|
|
10
|
+
from injector import inject
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HistoryManagerService:
|
|
14
|
+
"""
|
|
15
|
+
Manages conversation history for LLMs in a unified way.
|
|
16
|
+
Handles:
|
|
17
|
+
1. Server-side history (e.g., OpenAI response_ids).
|
|
18
|
+
2. Client-side history (e.g., Gemini message lists).
|
|
19
|
+
3. Database persistence retrieval (full chat history).
|
|
20
|
+
"""
|
|
21
|
+
TYPE_SERVER_SIDE = 'server_side' # For models like OpenAI
|
|
22
|
+
TYPE_CLIENT_SIDE = 'client_side' # For models like Gemini
|
|
23
|
+
|
|
24
|
+
GEMINI_MAX_TOKENS_CONTEXT_HISTORY = 200000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@inject
|
|
28
|
+
def __init__(self,
|
|
29
|
+
session_context: UserSessionContextService,
|
|
30
|
+
i18n: I18nService,
|
|
31
|
+
llm_query_repo: LLMQueryRepo,
|
|
32
|
+
profile_repo: ProfileRepo,
|
|
33
|
+
llm_client: Optional[llmClient] = None):
|
|
34
|
+
self.session_context = session_context
|
|
35
|
+
self.i18n = i18n
|
|
36
|
+
self.llm_query_repo = llm_query_repo
|
|
37
|
+
self.profile_repo = profile_repo
|
|
38
|
+
self.llm_client = llm_client
|
|
39
|
+
|
|
40
|
+
def initialize_context(self,
|
|
41
|
+
company_short_name: str,
|
|
42
|
+
user_identifier: str,
|
|
43
|
+
history_type: str,
|
|
44
|
+
prepared_context: str,
|
|
45
|
+
company: Company, model: str) -> Dict[str, Any]:
|
|
46
|
+
"""
|
|
47
|
+
Initializes a new conversation history.
|
|
48
|
+
"""
|
|
49
|
+
# 1. Clear existing history
|
|
50
|
+
self.session_context.clear_llm_history(company_short_name, user_identifier)
|
|
51
|
+
|
|
52
|
+
if history_type == self.TYPE_SERVER_SIDE:
|
|
53
|
+
# OpenAI: Send system prompt to API and store the resulting ID
|
|
54
|
+
response_id = self.llm_client.set_company_context(
|
|
55
|
+
company=company,
|
|
56
|
+
company_base_context=prepared_context,
|
|
57
|
+
model=model
|
|
58
|
+
)
|
|
59
|
+
self.session_context.save_last_response_id(company_short_name, user_identifier, response_id)
|
|
60
|
+
self.session_context.save_initial_response_id(company_short_name, user_identifier, response_id)
|
|
61
|
+
return {'response_id': response_id}
|
|
62
|
+
|
|
63
|
+
elif history_type == self.TYPE_CLIENT_SIDE:
|
|
64
|
+
# Gemini: Store system prompt as the first message in the list
|
|
65
|
+
context_history = [{"role": "user", "content": prepared_context}]
|
|
66
|
+
self.session_context.save_context_history(company_short_name, user_identifier, context_history)
|
|
67
|
+
return {}
|
|
68
|
+
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
def populate_request_params(self,
|
|
72
|
+
handle: Any,
|
|
73
|
+
user_turn_prompt: str,
|
|
74
|
+
ignore_history: bool = False) -> bool:
|
|
75
|
+
"""
|
|
76
|
+
Populates the request_params within the HistoryHandle.
|
|
77
|
+
Returns True if a rebuild is needed, False otherwise.
|
|
78
|
+
"""
|
|
79
|
+
if handle.type == self.TYPE_SERVER_SIDE:
|
|
80
|
+
previous_response_id = None
|
|
81
|
+
if ignore_history:
|
|
82
|
+
previous_response_id = self.session_context.get_initial_response_id(handle.company_short_name,
|
|
83
|
+
handle.user_identifier)
|
|
84
|
+
else:
|
|
85
|
+
previous_response_id = self.session_context.get_last_response_id(handle.company_short_name,
|
|
86
|
+
handle.user_identifier)
|
|
87
|
+
|
|
88
|
+
if not previous_response_id:
|
|
89
|
+
handle.request_params = {}
|
|
90
|
+
return True # Needs rebuild
|
|
91
|
+
|
|
92
|
+
handle.request_params = {'previous_response_id': previous_response_id}
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
elif handle.type == self.TYPE_CLIENT_SIDE:
|
|
96
|
+
context_history = self.session_context.get_context_history(handle.company_short_name,
|
|
97
|
+
handle.user_identifier) or []
|
|
98
|
+
|
|
99
|
+
if not context_history:
|
|
100
|
+
handle.request_params = {}
|
|
101
|
+
return True # Needs rebuild
|
|
102
|
+
|
|
103
|
+
if ignore_history and len(context_history) > 1:
|
|
104
|
+
# Keep only system prompt
|
|
105
|
+
context_history = [context_history[0]]
|
|
106
|
+
|
|
107
|
+
# For Gemini, we append the current user turn to the context sent to the API
|
|
108
|
+
context_history.append({"role": "user", "content": user_turn_prompt})
|
|
109
|
+
|
|
110
|
+
self._trim_context_history(context_history)
|
|
111
|
+
|
|
112
|
+
handle.request_params = {'context_history': context_history}
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
handle.request_params = {}
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def update_history(self,
|
|
119
|
+
history_handle: Any,
|
|
120
|
+
user_turn_prompt: str,
|
|
121
|
+
response: Dict[str, Any]):
|
|
122
|
+
"""Saves or updates the history after a successful LLM call."""
|
|
123
|
+
|
|
124
|
+
# We access the type from the handle
|
|
125
|
+
history_type = history_handle.type
|
|
126
|
+
company_short_name = history_handle.company_short_name
|
|
127
|
+
user_identifier = history_handle.user_identifier
|
|
128
|
+
|
|
129
|
+
if history_type == self.TYPE_SERVER_SIDE:
|
|
130
|
+
if "response_id" in response:
|
|
131
|
+
self.session_context.save_last_response_id(company_short_name, user_identifier,
|
|
132
|
+
response["response_id"])
|
|
133
|
+
|
|
134
|
+
elif history_type == self.TYPE_CLIENT_SIDE:
|
|
135
|
+
context_history = self.session_context.get_context_history(company_short_name,
|
|
136
|
+
user_identifier) or []
|
|
137
|
+
# Ensure the user prompt is recorded if not already.
|
|
138
|
+
# We check content equality to handle the case where the previous message was
|
|
139
|
+
# also 'user' (e.g., System Prompt) but different content.
|
|
140
|
+
last_content = context_history[-1].get("content") if context_history else None
|
|
141
|
+
|
|
142
|
+
if last_content != user_turn_prompt:
|
|
143
|
+
context_history.append({"role": "user", "content": user_turn_prompt})
|
|
144
|
+
|
|
145
|
+
if response.get('output'):
|
|
146
|
+
context_history.append({"role": "model", "content": response['output']})
|
|
147
|
+
|
|
148
|
+
self.session_context.save_context_history(company_short_name, user_identifier, context_history)
|
|
149
|
+
|
|
150
|
+
def _trim_context_history(self, context_history: list):
|
|
151
|
+
"""Internal helper to keep token usage within limits for client-side history."""
|
|
152
|
+
if not context_history or len(context_history) <= 1:
|
|
153
|
+
return
|
|
154
|
+
try:
|
|
155
|
+
total_tokens = sum(self.llm_client.count_tokens(json.dumps(message)) for message in context_history)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logging.error(f"Error counting tokens for history: {e}.")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
while total_tokens > self.GEMINI_MAX_TOKENS_CONTEXT_HISTORY and len(context_history) > 1:
|
|
161
|
+
try:
|
|
162
|
+
# Remove the oldest message after system prompt
|
|
163
|
+
removed_message = context_history.pop(1)
|
|
164
|
+
removed_tokens = self.llm_client.count_tokens(json.dumps(removed_message))
|
|
165
|
+
total_tokens -= removed_tokens
|
|
166
|
+
logging.warning(
|
|
167
|
+
f"History tokens exceed limit. Removed old message. New total: {total_tokens} tokens."
|
|
168
|
+
)
|
|
169
|
+
except IndexError:
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
# --- Database History Management (Legacy HistoryService) ---
|
|
173
|
+
|
|
174
|
+
def get_full_history(self, company_short_name: str, user_identifier: str) -> dict:
|
|
175
|
+
"""Retrieves the full persisted history from the database."""
|
|
176
|
+
try:
|
|
177
|
+
company = self.profile_repo.get_company_by_short_name(company_short_name)
|
|
178
|
+
if not company:
|
|
179
|
+
return {"error": self.i18n.t('errors.company_not_found', company_short_name=company_short_name)}
|
|
180
|
+
|
|
181
|
+
history = self.llm_query_repo.get_history(company, user_identifier)
|
|
182
|
+
if not history:
|
|
183
|
+
return {'message': 'empty history', 'history': []}
|
|
184
|
+
|
|
185
|
+
history_list = [query.to_dict() for query in history]
|
|
186
|
+
return {'message': 'history loaded ok', 'history': history_list}
|
|
187
|
+
|
|
188
|
+
except Exception as e:
|
|
189
|
+
return {'error': str(e)}
|
|
@@ -17,7 +17,7 @@ class JWTService:
|
|
|
17
17
|
def __init__(self, app: Flask):
|
|
18
18
|
# Acceder a la configuración directamente desde app.config
|
|
19
19
|
try:
|
|
20
|
-
self.secret_key = app.config['
|
|
20
|
+
self.secret_key = app.config['IATOOLKIT_SECRET_KEY']
|
|
21
21
|
self.algorithm = app.config['JWT_ALGORITHM']
|
|
22
22
|
except KeyError as e:
|
|
23
23
|
logging.error(f"missing JWT configuration: {e}.")
|
|
@@ -48,6 +48,7 @@ class LanguageService:
|
|
|
48
48
|
def get_current_language(self) -> str:
|
|
49
49
|
"""
|
|
50
50
|
Determines and caches the language for the current request using a priority order:
|
|
51
|
+
0. Query parameter '?lang=<code>' (highest priority; e.g., 'en', 'es').
|
|
51
52
|
1. User's preference (from their profile).
|
|
52
53
|
2. Company's default language.
|
|
53
54
|
3. System-wide fallback language ('es').
|
|
@@ -56,6 +57,12 @@ class LanguageService:
|
|
|
56
57
|
return g.lang
|
|
57
58
|
|
|
58
59
|
try:
|
|
60
|
+
# Priority 0: Explicit query parameter (?lang=)
|
|
61
|
+
lang_arg = request.args.get('lang')
|
|
62
|
+
if lang_arg:
|
|
63
|
+
g.lang = lang_arg
|
|
64
|
+
return g.lang
|
|
65
|
+
|
|
59
66
|
# Priority 1: User's preferred language
|
|
60
67
|
user_identifier = SessionManager.get('user_identifier')
|
|
61
68
|
if user_identifier:
|
|
@@ -74,10 +81,9 @@ class LanguageService:
|
|
|
74
81
|
g.lang = company_language
|
|
75
82
|
return g.lang
|
|
76
83
|
except Exception as e:
|
|
77
|
-
logging.info(f"Could not determine language, falling back to default. Reason: {e}")
|
|
78
84
|
pass
|
|
79
85
|
|
|
80
86
|
# Priority 3: System-wide fallback
|
|
81
|
-
logging.
|
|
87
|
+
logging.debug(f"Language determined by system fallback: {self.FALLBACK_LANGUAGE}")
|
|
82
88
|
g.lang = self.FALLBACK_LANGUAGE
|
|
83
89
|
return g.lang
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Copyright (c) 2024 Fernando Libedinsky
|
|
2
|
+
# Product: IAToolkit
|
|
3
|
+
#
|
|
4
|
+
# IAToolkit is open source software.
|
|
5
|
+
|
|
6
|
+
import jwt
|
|
7
|
+
import os
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from iatoolkit.common.exceptions import IAToolkitException
|
|
11
|
+
from injector import inject, singleton
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@singleton
|
|
15
|
+
class LicenseService:
|
|
16
|
+
"""
|
|
17
|
+
Manages system restrictions and features based on a license (JWT).
|
|
18
|
+
If no license or an invalid license is provided, Community Edition limits apply.
|
|
19
|
+
"""
|
|
20
|
+
@inject
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.limits = self._load_limits()
|
|
23
|
+
|
|
24
|
+
def _load_limits(self):
|
|
25
|
+
# 1. Define default limits (Community Edition)
|
|
26
|
+
default_limits = {
|
|
27
|
+
"license_type": "Community Edition",
|
|
28
|
+
"plan": "Open Source (Community Edition)",
|
|
29
|
+
"max_companies": 1,
|
|
30
|
+
"max_tools": 3,
|
|
31
|
+
"features": {
|
|
32
|
+
"multi_tenant": False,
|
|
33
|
+
"rag_advanced": False,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return default_limits
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --- Information Getters ---
|
|
40
|
+
def get_license_type(self) -> str:
|
|
41
|
+
return self.limits.get("license_type", "Community Edition")
|
|
42
|
+
|
|
43
|
+
def get_plan_name(self) -> str:
|
|
44
|
+
return self.limits.get("plan", "Unknown")
|
|
45
|
+
|
|
46
|
+
def get_max_companies(self) -> int:
|
|
47
|
+
return self.limits.get("max_companies", 1)
|
|
48
|
+
|
|
49
|
+
def get_max_tools_per_company(self) -> int:
|
|
50
|
+
return self.limits.get("max_tools", 3)
|
|
51
|
+
|
|
52
|
+
def get_license_info(self) -> str:
|
|
53
|
+
return f"Plan: {self.get_plan_name()}, Companies: {self.get_max_companies()}, Tools: {self.get_max_tools_per_company()}"
|
|
54
|
+
|
|
55
|
+
# --- Restriction Validators ---
|
|
56
|
+
|
|
57
|
+
def validate_company_limit(self, current_count: int):
|
|
58
|
+
"""Raises exception if the limit of active companies is exceeded."""
|
|
59
|
+
limit = self.get_max_companies()
|
|
60
|
+
# -1 means unlimited
|
|
61
|
+
if limit != -1 and current_count > limit:
|
|
62
|
+
raise IAToolkitException(
|
|
63
|
+
IAToolkitException.ErrorType.PERMISSION,
|
|
64
|
+
f"Company limit ({limit}) reached for plan '{self.get_plan_name()}'."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_tool_config_limit(self, tools_config: list):
|
|
69
|
+
"""Validates a configuration list before processing it."""
|
|
70
|
+
limit = self.get_max_tools_per_company()
|
|
71
|
+
if limit != -1 and len(tools_config) > limit:
|
|
72
|
+
raise IAToolkitException(
|
|
73
|
+
IAToolkitException.ErrorType.PERMISSION,
|
|
74
|
+
f"Configuration defines {len(tools_config)} tools, but limit is {limit}."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# --- Feature Gating Validators ---
|
|
78
|
+
|
|
79
|
+
def has_feature(self, feature_key: str) -> bool:
|
|
80
|
+
"""Checks if a specific feature is enabled in the license."""
|
|
81
|
+
features = self.limits.get("features", {})
|
|
82
|
+
return features.get(feature_key, False)
|
|
@@ -3,43 +3,40 @@
|
|
|
3
3
|
#
|
|
4
4
|
# IAToolkit is open source software.
|
|
5
5
|
|
|
6
|
-
from iatoolkit.
|
|
6
|
+
from iatoolkit.services.configuration_service import ConfigurationService
|
|
7
7
|
from iatoolkit.services.i18n_service import I18nService
|
|
8
|
+
from iatoolkit.infra.brevo_mail_app import BrevoMailApp
|
|
8
9
|
from injector import inject
|
|
9
10
|
from pathlib import Path
|
|
10
|
-
from iatoolkit.common.exceptions import IAToolkitException
|
|
11
11
|
import base64
|
|
12
|
+
import os
|
|
13
|
+
import smtplib
|
|
14
|
+
from email.message import EmailMessage
|
|
15
|
+
from iatoolkit.common.exceptions import IAToolkitException
|
|
16
|
+
|
|
12
17
|
|
|
13
18
|
TEMP_DIR = Path("static/temp")
|
|
14
19
|
|
|
15
20
|
class MailService:
|
|
16
21
|
@inject
|
|
17
22
|
def __init__(self,
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
config_service: ConfigurationService,
|
|
24
|
+
mail_app: BrevoMailApp,
|
|
25
|
+
i18n_service: I18nService,
|
|
26
|
+
brevo_mail_app: BrevoMailApp):
|
|
20
27
|
self.mail_app = mail_app
|
|
28
|
+
self.config_service = config_service
|
|
21
29
|
self.i18n_service = i18n_service
|
|
30
|
+
self.brevo_mail_app = brevo_mail_app
|
|
22
31
|
|
|
23
32
|
|
|
24
|
-
def
|
|
25
|
-
# Defensa simple contra path traversal
|
|
26
|
-
if not token or "/" in token or "\\" in token or token.startswith("."):
|
|
27
|
-
raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
|
|
28
|
-
"attachment_token invalid")
|
|
29
|
-
path = TEMP_DIR / token
|
|
30
|
-
if not path.is_file():
|
|
31
|
-
raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
|
|
32
|
-
f"attach file not found: {token}")
|
|
33
|
-
return path.read_bytes()
|
|
34
|
-
|
|
35
|
-
def send_mail(self, **kwargs):
|
|
36
|
-
from_email = kwargs.get('from_email', 'iatoolkit@iatoolkit.com')
|
|
33
|
+
def send_mail(self, company_short_name: str, **kwargs):
|
|
37
34
|
recipient = kwargs.get('recipient')
|
|
38
35
|
subject = kwargs.get('subject')
|
|
39
36
|
body = kwargs.get('body')
|
|
40
37
|
attachments = kwargs.get('attachments')
|
|
41
38
|
|
|
42
|
-
# Normalizar a payload de
|
|
39
|
+
# Normalizar a payload de BrevoMailApp (name + base64 content)
|
|
43
40
|
norm_attachments = []
|
|
44
41
|
for a in attachments or []:
|
|
45
42
|
if a.get("attachment_token"):
|
|
@@ -55,13 +52,162 @@ class MailService:
|
|
|
55
52
|
"content": a["content"]
|
|
56
53
|
})
|
|
57
54
|
|
|
58
|
-
|
|
55
|
+
# build provider configuration from company.yaml
|
|
56
|
+
provider, provider_config = self._build_provider_config(company_short_name)
|
|
57
|
+
|
|
58
|
+
# define the email sender
|
|
59
|
+
sender = {
|
|
60
|
+
"email": provider_config.get("sender_email"),
|
|
61
|
+
"name": provider_config.get("sender_name"),
|
|
62
|
+
}
|
|
59
63
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
# select provider and send the email through it
|
|
65
|
+
if provider == "brevo_mail":
|
|
66
|
+
response = self.brevo_mail_app.send_email(
|
|
67
|
+
provider_config=provider_config,
|
|
68
|
+
sender=sender,
|
|
69
|
+
to=recipient,
|
|
70
|
+
subject=subject,
|
|
71
|
+
body=body,
|
|
72
|
+
attachments=norm_attachments
|
|
73
|
+
)
|
|
74
|
+
elif provider == "smtplib":
|
|
75
|
+
response = self._send_with_smtplib(
|
|
76
|
+
provider_config=provider_config,
|
|
77
|
+
sender=sender,
|
|
78
|
+
recipient=recipient,
|
|
79
|
+
subject=subject,
|
|
80
|
+
body=body,
|
|
81
|
+
attachments=norm_attachments,
|
|
82
|
+
)
|
|
83
|
+
response = None
|
|
84
|
+
else:
|
|
85
|
+
raise IAToolkitException(
|
|
86
|
+
IAToolkitException.ErrorType.MAIL_ERROR,
|
|
87
|
+
f"Unknown mail provider '{provider}'"
|
|
88
|
+
)
|
|
66
89
|
|
|
67
90
|
return self.i18n_service.t('services.mail_sent')
|
|
91
|
+
|
|
92
|
+
def _build_provider_config(self, company_short_name: str) -> tuple[str, dict]:
|
|
93
|
+
"""
|
|
94
|
+
Determina el provider activo (brevo_mail / smtplib) y construye
|
|
95
|
+
el diccionario de configuración a partir de las variables de entorno
|
|
96
|
+
cuyos nombres están en company.yaml (mail_provider).
|
|
97
|
+
"""
|
|
98
|
+
# get company mail configuration and provider
|
|
99
|
+
mail_config = self.config_service.get_configuration(company_short_name, "mail_provider")
|
|
100
|
+
provider = mail_config.get("provider", "brevo_mail")
|
|
101
|
+
|
|
102
|
+
# get mail common parameteres
|
|
103
|
+
sender_email = mail_config.get("sender_email")
|
|
104
|
+
sender_name = mail_config.get("sender_name")
|
|
105
|
+
|
|
106
|
+
# get parameters depending on provider
|
|
107
|
+
if provider == "brevo_mail":
|
|
108
|
+
brevo_cfg = mail_config.get("brevo_mail", {})
|
|
109
|
+
api_key_env = brevo_cfg.get("brevo_api", "BREVO_API_KEY")
|
|
110
|
+
return provider, {
|
|
111
|
+
"api_key": os.getenv(api_key_env),
|
|
112
|
+
"sender_name": sender_name,
|
|
113
|
+
"sender_email": sender_email,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if provider == "smtplib":
|
|
117
|
+
smtp_cfg = mail_config.get("smtplib", {})
|
|
118
|
+
host = os.getenv(smtp_cfg.get("host_env", "SMTP_HOST"))
|
|
119
|
+
port = os.getenv(smtp_cfg.get("port_env", "SMTP_PORT"))
|
|
120
|
+
username = os.getenv(smtp_cfg.get("username_env", "SMTP_USERNAME"))
|
|
121
|
+
password = os.getenv(smtp_cfg.get("password_env", "SMTP_PASSWORD"))
|
|
122
|
+
use_tls = os.getenv(smtp_cfg.get("use_tls_env", "SMTP_USE_TLS"))
|
|
123
|
+
use_ssl = os.getenv(smtp_cfg.get("use_ssl_env", "SMTP_USE_SSL"))
|
|
124
|
+
|
|
125
|
+
return provider, {
|
|
126
|
+
"host": host,
|
|
127
|
+
"port": int(port) if port is not None else None,
|
|
128
|
+
"username": username,
|
|
129
|
+
"password": password,
|
|
130
|
+
"use_tls": str(use_tls).lower() == "true",
|
|
131
|
+
"use_ssl": str(use_ssl).lower() == "true",
|
|
132
|
+
"sender_name": sender_name,
|
|
133
|
+
"sender_email": sender_email,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# Fallback simple si el provider no es reconocido
|
|
137
|
+
raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
|
|
138
|
+
f"missing mail provider in mail configuration for company '{company_short_name}'")
|
|
139
|
+
|
|
140
|
+
def _send_with_smtplib(self,
|
|
141
|
+
provider_config: dict,
|
|
142
|
+
sender: dict,
|
|
143
|
+
recipient: str,
|
|
144
|
+
subject: str,
|
|
145
|
+
body: str,
|
|
146
|
+
attachments: list[dict] | None):
|
|
147
|
+
"""
|
|
148
|
+
Envía correo usando smtplib, utilizando la configuración normalizada
|
|
149
|
+
en provider_config.
|
|
150
|
+
"""
|
|
151
|
+
host = provider_config.get("host")
|
|
152
|
+
port = provider_config.get("port")
|
|
153
|
+
username = provider_config.get("username")
|
|
154
|
+
password = provider_config.get("password")
|
|
155
|
+
use_tls = provider_config.get("use_tls")
|
|
156
|
+
use_ssl = provider_config.get("use_ssl")
|
|
157
|
+
|
|
158
|
+
if not host or not port:
|
|
159
|
+
raise IAToolkitException(
|
|
160
|
+
IAToolkitException.ErrorType.MAIL_ERROR,
|
|
161
|
+
"smtplib configuration is incomplete (host/port missing)"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
msg = EmailMessage()
|
|
165
|
+
msg["From"] = f"{sender.get('name', '')} <{sender.get('email')}>"
|
|
166
|
+
msg["To"] = recipient
|
|
167
|
+
msg["Subject"] = subject
|
|
168
|
+
msg.set_content(body, subtype="html")
|
|
169
|
+
|
|
170
|
+
# Adjuntos: ya vienen como filename + base64 content
|
|
171
|
+
for a in attachments or []:
|
|
172
|
+
filename = a.get("filename")
|
|
173
|
+
content_b64 = a.get("content")
|
|
174
|
+
if not filename or not content_b64:
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
raw = base64.b64decode(content_b64, validate=True)
|
|
178
|
+
except Exception:
|
|
179
|
+
raise IAToolkitException(
|
|
180
|
+
IAToolkitException.ErrorType.MAIL_ERROR,
|
|
181
|
+
f"Invalid base64 for attachment '{filename}'"
|
|
182
|
+
)
|
|
183
|
+
msg.add_attachment(
|
|
184
|
+
raw,
|
|
185
|
+
maintype="application",
|
|
186
|
+
subtype="octet-stream",
|
|
187
|
+
filename=filename,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if use_ssl:
|
|
191
|
+
with smtplib.SMTP_SSL(host, port) as server:
|
|
192
|
+
if username and password:
|
|
193
|
+
server.login(username, password)
|
|
194
|
+
server.send_message(msg)
|
|
195
|
+
else:
|
|
196
|
+
with smtplib.SMTP(host, port) as server:
|
|
197
|
+
if use_tls:
|
|
198
|
+
server.starttls()
|
|
199
|
+
if username and password:
|
|
200
|
+
server.login(username, password)
|
|
201
|
+
server.send_message(msg)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _read_token_bytes(self, token: str) -> bytes:
|
|
205
|
+
# Defensa simple contra path traversal
|
|
206
|
+
if not token or "/" in token or "\\" in token or token.startswith("."):
|
|
207
|
+
raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
|
|
208
|
+
"attachment_token invalid")
|
|
209
|
+
path = TEMP_DIR / token
|
|
210
|
+
if not path.is_file():
|
|
211
|
+
raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
|
|
212
|
+
f"attach file not found: {token}")
|
|
213
|
+
return path.read_bytes()
|