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.
Files changed (86) hide show
  1. iatoolkit/__init__.py +15 -5
  2. iatoolkit/base_company.py +4 -58
  3. iatoolkit/cli_commands.py +6 -7
  4. iatoolkit/common/exceptions.py +1 -0
  5. iatoolkit/common/routes.py +12 -28
  6. iatoolkit/common/util.py +7 -1
  7. iatoolkit/company_registry.py +50 -14
  8. iatoolkit/{iatoolkit.py → core.py} +54 -55
  9. iatoolkit/infra/{mail_app.py → brevo_mail_app.py} +15 -37
  10. iatoolkit/infra/llm_client.py +9 -5
  11. iatoolkit/locales/en.yaml +10 -2
  12. iatoolkit/locales/es.yaml +171 -162
  13. iatoolkit/repositories/database_manager.py +59 -14
  14. iatoolkit/repositories/llm_query_repo.py +34 -22
  15. iatoolkit/repositories/models.py +16 -18
  16. iatoolkit/repositories/profile_repo.py +5 -10
  17. iatoolkit/repositories/vs_repo.py +9 -4
  18. iatoolkit/services/auth_service.py +1 -1
  19. iatoolkit/services/branding_service.py +1 -1
  20. iatoolkit/services/company_context_service.py +19 -11
  21. iatoolkit/services/configuration_service.py +219 -46
  22. iatoolkit/services/dispatcher_service.py +31 -225
  23. iatoolkit/services/document_service.py +10 -1
  24. iatoolkit/services/embedding_service.py +43 -41
  25. iatoolkit/services/excel_service.py +50 -2
  26. iatoolkit/services/history_manager_service.py +189 -0
  27. iatoolkit/services/jwt_service.py +1 -1
  28. iatoolkit/services/language_service.py +8 -2
  29. iatoolkit/services/license_service.py +82 -0
  30. iatoolkit/services/mail_service.py +171 -25
  31. iatoolkit/services/profile_service.py +37 -32
  32. iatoolkit/services/{prompt_manager_service.py → prompt_service.py} +110 -1
  33. iatoolkit/services/query_service.py +192 -191
  34. iatoolkit/services/sql_service.py +63 -12
  35. iatoolkit/services/tool_service.py +231 -0
  36. iatoolkit/services/user_feedback_service.py +18 -6
  37. iatoolkit/services/user_session_context_service.py +18 -0
  38. iatoolkit/static/images/iatoolkit_core.png +0 -0
  39. iatoolkit/static/images/iatoolkit_logo.png +0 -0
  40. iatoolkit/static/js/chat_feedback_button.js +1 -1
  41. iatoolkit/static/js/chat_help_content.js +4 -4
  42. iatoolkit/static/js/chat_main.js +17 -5
  43. iatoolkit/static/js/chat_onboarding_button.js +1 -1
  44. iatoolkit/static/styles/chat_iatoolkit.css +1 -1
  45. iatoolkit/static/styles/chat_public.css +28 -0
  46. iatoolkit/static/styles/documents.css +598 -0
  47. iatoolkit/static/styles/landing_page.css +223 -7
  48. iatoolkit/system_prompts/__init__.py +0 -0
  49. iatoolkit/system_prompts/query_main.prompt +2 -1
  50. iatoolkit/system_prompts/sql_rules.prompt +47 -12
  51. iatoolkit/templates/_company_header.html +30 -5
  52. iatoolkit/templates/_login_widget.html +3 -3
  53. iatoolkit/templates/chat.html +1 -1
  54. iatoolkit/templates/forgot_password.html +3 -2
  55. iatoolkit/templates/onboarding_shell.html +1 -1
  56. iatoolkit/templates/signup.html +3 -0
  57. iatoolkit/views/base_login_view.py +1 -1
  58. iatoolkit/views/change_password_view.py +1 -1
  59. iatoolkit/views/forgot_password_view.py +9 -4
  60. iatoolkit/views/history_api_view.py +3 -3
  61. iatoolkit/views/home_view.py +4 -2
  62. iatoolkit/views/init_context_api_view.py +1 -1
  63. iatoolkit/views/llmquery_api_view.py +4 -3
  64. iatoolkit/views/{file_store_api_view.py → load_document_api_view.py} +1 -1
  65. iatoolkit/views/login_view.py +17 -5
  66. iatoolkit/views/logout_api_view.py +10 -2
  67. iatoolkit/views/prompt_api_view.py +1 -1
  68. iatoolkit/views/root_redirect_view.py +22 -0
  69. iatoolkit/views/signup_view.py +12 -4
  70. iatoolkit/views/static_page_view.py +27 -0
  71. iatoolkit/views/verify_user_view.py +1 -1
  72. iatoolkit-0.91.1.dist-info/METADATA +268 -0
  73. iatoolkit-0.91.1.dist-info/RECORD +125 -0
  74. iatoolkit-0.91.1.dist-info/licenses/LICENSE_COMMUNITY.md +15 -0
  75. iatoolkit/services/history_service.py +0 -37
  76. iatoolkit/templates/about.html +0 -13
  77. iatoolkit/templates/index.html +0 -145
  78. iatoolkit/templates/login_simulation.html +0 -45
  79. iatoolkit/views/external_login_view.py +0 -73
  80. iatoolkit/views/index_view.py +0 -14
  81. iatoolkit/views/login_simulation_view.py +0 -93
  82. iatoolkit-0.71.2.dist-info/METADATA +0 -276
  83. iatoolkit-0.71.2.dist-info/RECORD +0 -122
  84. {iatoolkit-0.71.2.dist-info → iatoolkit-0.91.1.dist-info}/WHEEL +0 -0
  85. {iatoolkit-0.71.2.dist-info → iatoolkit-0.91.1.dist-info}/licenses/LICENSE +0 -0
  86. {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['JWT_SECRET_KEY']
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.info(f"Language determined by system fallback: {self.FALLBACK_LANGUAGE}")
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.infra.mail_app import MailApp
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
- mail_app: MailApp,
19
- i18n_service: I18nService):
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 _read_token_bytes(self, token: str) -> bytes:
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 MailApp (name + base64 content)
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
- self.sender = {"email": from_email, "name": "IAToolkit"}
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
- response = self.mail_app.send_email(
61
- sender=self.sender,
62
- to=recipient,
63
- subject=subject,
64
- body=body,
65
- attachments=norm_attachments)
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()