You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
orquestrador/app/services/integrations/providers.py

199 lines
6.6 KiB
Python

from collections.abc import Mapping
from typing import Any
import httpx
from app.core.settings import settings
class IntegrationProviderError(RuntimeError):
"""Erro de transporte ou configuracao em providers externos."""
def _clean_text(value: Any) -> str | None:
text = str(value or "").strip()
return text or None
def _normalize_address(value: Any, *, fallback_name: Any = None) -> dict[str, str] | None:
if isinstance(value, Mapping):
email = _clean_text(value.get("email"))
name = _clean_text(value.get("name"))
else:
email = _clean_text(value)
name = _clean_text(fallback_name)
if not email:
return None
address = {"email": email}
if name:
address["name"] = name
return address
def _normalize_address_list(value: Any) -> list[dict[str, str]]:
if value is None:
return []
candidates = value if isinstance(value, (list, tuple, set)) else [value]
addresses: list[dict[str, str]] = []
for candidate in candidates:
address = _normalize_address(candidate)
if address:
addresses.append(address)
return addresses
def _normalize_tags(*groups: Any) -> list[str]:
merged: list[str] = []
for group in groups:
if group is None:
continue
candidates = group if isinstance(group, (list, tuple, set)) else [group]
for candidate in candidates:
tag = _clean_text(candidate)
if tag and tag not in merged:
merged.append(tag)
return merged
def _normalize_headers(value: Any) -> dict[str, str]:
if not isinstance(value, Mapping):
return {}
headers: dict[str, str] = {}
for key, header_value in value.items():
normalized_key = _clean_text(key)
normalized_value = _clean_text(header_value)
if normalized_key and normalized_value:
headers[normalized_key] = normalized_value
return headers
class BrevoEmailProvider:
provider_name = "brevo_email"
def __init__(self) -> None:
self.base_url = str(settings.brevo_base_url or "https://api.brevo.com/v3").rstrip("/")
self.api_key = str(settings.brevo_api_key or "").strip()
self.sender_email = str(settings.brevo_sender_email or "").strip()
self.sender_name = str(settings.brevo_sender_name or "Orquestrador").strip() or "Orquestrador"
self.timeout_seconds = max(1, int(settings.brevo_request_timeout_seconds or 10))
def _normalize_provider_config(self, provider_config: Mapping[str, Any] | None) -> dict[str, Any]:
if not isinstance(provider_config, Mapping):
return {}
return dict(provider_config)
def _resolve_sender(self, provider_config: Mapping[str, Any]) -> tuple[str | None, str]:
sender = provider_config.get("sender")
sender_payload = _normalize_address(sender) if sender is not None else None
sender_email = (
(sender_payload or {}).get("email")
or _clean_text(provider_config.get("sender_email"))
or self.sender_email
)
sender_name = (
(sender_payload or {}).get("name")
or _clean_text(provider_config.get("sender_name"))
or self.sender_name
)
return sender_email, sender_name or "Orquestrador"
def is_configured(self, provider_config: Mapping[str, Any] | None = None) -> bool:
normalized_provider_config = self._normalize_provider_config(provider_config)
sender_email, _sender_name = self._resolve_sender(normalized_provider_config)
return bool(self.api_key and sender_email)
async def send_email(
self,
*,
to_email: str,
to_name: str | None,
subject: str,
body: str,
tags: list[str] | None = None,
provider_config: Mapping[str, Any] | None = None,
) -> dict:
normalized_provider_config = self._normalize_provider_config(provider_config)
sender_email, sender_name = self._resolve_sender(normalized_provider_config)
if not self.is_configured(normalized_provider_config):
raise IntegrationProviderError(
"Brevo nao configurado. Defina BREVO_API_KEY e BREVO_SENDER_EMAIL para enviar emails."
)
payload = {
"sender": {
"email": sender_email,
"name": sender_name,
},
"to": [
{
"email": str(to_email or "").strip(),
**({"name": str(to_name).strip()} if str(to_name or "").strip() else {}),
}
],
"subject": str(subject or "").strip(),
"textContent": str(body or "").strip(),
}
reply_to = _normalize_address(
normalized_provider_config.get("reply_to") or normalized_provider_config.get("reply_to_email"),
fallback_name=normalized_provider_config.get("reply_to_name"),
)
if reply_to:
payload["replyTo"] = reply_to
cc = _normalize_address_list(normalized_provider_config.get("cc"))
if cc:
payload["cc"] = cc
bcc = _normalize_address_list(normalized_provider_config.get("bcc"))
if bcc:
payload["bcc"] = bcc
merged_tags = _normalize_tags(tags, normalized_provider_config.get("tags"))
if merged_tags:
payload["tags"] = merged_tags
headers = _normalize_headers(normalized_provider_config.get("headers"))
if headers:
payload["headers"] = headers
html_content = _clean_text(normalized_provider_config.get("html_content"))
if html_content:
payload["htmlContent"] = html_content
headers = {
"accept": "application/json",
"content-type": "application/json",
"api-key": self.api_key,
}
try:
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
response = await client.post(
f"{self.base_url}/smtp/email",
headers=headers,
json=payload,
)
except httpx.HTTPError as exc:
raise IntegrationProviderError(f"Falha ao enviar email via Brevo: {exc}") from exc
if response.status_code >= 400:
raise IntegrationProviderError(
f"Brevo retornou erro {response.status_code}: {response.text[:300]}"
)
try:
data = response.json()
except ValueError:
data = {}
return {
"provider": self.provider_name,
"message_id": data.get("messageId"),
"response": data,
}