Spaces:
Sleeping
Sleeping
Commit ·
cf487cf
1
Parent(s): 9b19962
API robusta + Transformers + front + fix Turso
Browse files- .env.example +15 -1
- scripts/test_gmail_oauth.py +101 -0
- src/config/settings.py +27 -2
- src/delivery/clickup_sender.py +26 -4
- src/delivery/email_sender.py +70 -7
.env.example
CHANGED
|
@@ -74,12 +74,26 @@ ENABLE_AI_INTERPRETATION=true
|
|
| 74 |
REPORT_TIMEZONE=America/Sao_Paulo
|
| 75 |
REPORT_RECIPIENT_EMAIL=
|
| 76 |
|
| 77 |
-
# ---------------------- SMTP (
|
|
|
|
|
|
|
| 78 |
SMTP_HOST=
|
| 79 |
SMTP_PORT=
|
| 80 |
SMTP_USER=
|
| 81 |
SMTP_PASSWORD=
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
# --------------------- ClickUp (opcional) ------------------
|
| 84 |
CLICKUP_API_TOKEN=
|
| 85 |
CLICKUP_LIST_ID=
|
|
|
|
| 74 |
REPORT_TIMEZONE=America/Sao_Paulo
|
| 75 |
REPORT_RECIPIENT_EMAIL=
|
| 76 |
|
| 77 |
+
# ---------------------- E-mail: SMTP (uso local) -----------
|
| 78 |
+
# Funciona localmente. Em Hugging Face Spaces o SMTP é BLOQUEADO — use a
|
| 79 |
+
# Gmail API (abaixo), que envia por HTTPS.
|
| 80 |
SMTP_HOST=
|
| 81 |
SMTP_PORT=
|
| 82 |
SMTP_USER=
|
| 83 |
SMTP_PASSWORD=
|
| 84 |
|
| 85 |
+
# ------------- E-mail: Gmail API (HTTP/OAuth) --------------
|
| 86 |
+
# Preferido em produção/online (passa por firewalls que bloqueiam SMTP).
|
| 87 |
+
# Como obter o refresh token: Google Cloud Console -> ativar "Gmail API" ->
|
| 88 |
+
# criar credencial OAuth -> no OAuth Playground (https://developers.google.com/oauthplayground)
|
| 89 |
+
# usar suas credenciais e autorizar o escopo
|
| 90 |
+
# https://www.googleapis.com/auth/gmail.send -> trocar pelo refresh token.
|
| 91 |
+
GMAIL_CLIENT_ID=
|
| 92 |
+
GMAIL_CLIENT_SECRET=
|
| 93 |
+
GMAIL_REFRESH_TOKEN=
|
| 94 |
+
# E-mail remetente (a conta autorizada no OAuth acima).
|
| 95 |
+
GMAIL_SENDER=
|
| 96 |
+
|
| 97 |
# --------------------- ClickUp (opcional) ------------------
|
| 98 |
CLICKUP_API_TOKEN=
|
| 99 |
CLICKUP_LIST_ID=
|
scripts/test_gmail_oauth.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Testa o envio de e-mail via Gmail API (OAuth refresh token).
|
| 2 |
+
|
| 3 |
+
Valida as credenciais antes do deploy. Envia um e-mail curto de teste.
|
| 4 |
+
|
| 5 |
+
Uso:
|
| 6 |
+
python scripts/test_gmail_oauth.py
|
| 7 |
+
|
| 8 |
+
Variáveis necessárias no .env:
|
| 9 |
+
GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN,
|
| 10 |
+
GMAIL_SENDER (remetente autorizado), REPORT_RECIPIENT_EMAIL (destinatário).
|
| 11 |
+
|
| 12 |
+
Não imprime tokens nem segredos.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import base64
|
| 16 |
+
import os
|
| 17 |
+
from email.mime.text import MIMEText
|
| 18 |
+
|
| 19 |
+
import requests
|
| 20 |
+
from dotenv import load_dotenv
|
| 21 |
+
|
| 22 |
+
load_dotenv()
|
| 23 |
+
|
| 24 |
+
CLIENT_ID = os.getenv("GMAIL_CLIENT_ID", "")
|
| 25 |
+
CLIENT_SECRET = os.getenv("GMAIL_CLIENT_SECRET", "")
|
| 26 |
+
REFRESH_TOKEN = os.getenv("GMAIL_REFRESH_TOKEN", "")
|
| 27 |
+
SENDER = os.getenv("GMAIL_SENDER", "")
|
| 28 |
+
RECIPIENT = os.getenv("REPORT_RECIPIENT_EMAIL", "")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def validar() -> None:
|
| 32 |
+
"""Confere se as variáveis obrigatórias estão preenchidas."""
|
| 33 |
+
faltando = [
|
| 34 |
+
nome
|
| 35 |
+
for nome, valor in {
|
| 36 |
+
"GMAIL_CLIENT_ID": CLIENT_ID,
|
| 37 |
+
"GMAIL_CLIENT_SECRET": CLIENT_SECRET,
|
| 38 |
+
"GMAIL_REFRESH_TOKEN": REFRESH_TOKEN,
|
| 39 |
+
"GMAIL_SENDER": SENDER,
|
| 40 |
+
"REPORT_RECIPIENT_EMAIL": RECIPIENT,
|
| 41 |
+
}.items()
|
| 42 |
+
if not valor
|
| 43 |
+
]
|
| 44 |
+
if faltando:
|
| 45 |
+
raise SystemExit(f"Variáveis ausentes no .env: {', '.join(faltando)}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def obter_access_token() -> str:
|
| 49 |
+
"""Troca o refresh token por um access token (sem imprimir segredos)."""
|
| 50 |
+
resp = requests.post(
|
| 51 |
+
"https://oauth2.googleapis.com/token",
|
| 52 |
+
data={
|
| 53 |
+
"grant_type": "refresh_token",
|
| 54 |
+
"client_id": CLIENT_ID,
|
| 55 |
+
"client_secret": CLIENT_SECRET,
|
| 56 |
+
"refresh_token": REFRESH_TOKEN,
|
| 57 |
+
},
|
| 58 |
+
timeout=30,
|
| 59 |
+
)
|
| 60 |
+
if resp.status_code != 200:
|
| 61 |
+
try:
|
| 62 |
+
erro = resp.json()
|
| 63 |
+
detalhe = f"{erro.get('error')} - {erro.get('error_description', '')}"
|
| 64 |
+
except ValueError:
|
| 65 |
+
detalhe = "resposta inválida do servidor OAuth."
|
| 66 |
+
print("Falha no OAuth do Google. Status:", resp.status_code, "-", detalhe.strip(" -"))
|
| 67 |
+
raise SystemExit(1)
|
| 68 |
+
return resp.json()["access_token"]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def main() -> None:
|
| 72 |
+
"""Executa o teste de envio."""
|
| 73 |
+
validar()
|
| 74 |
+
print("Obtendo access token do Google...")
|
| 75 |
+
token = obter_access_token()
|
| 76 |
+
print("Access token obtido com sucesso.")
|
| 77 |
+
|
| 78 |
+
mensagem = MIMEText(
|
| 79 |
+
"Teste de envio do Analytical-Force via Gmail API. Se você recebeu, está funcionando.",
|
| 80 |
+
"plain",
|
| 81 |
+
"utf-8",
|
| 82 |
+
)
|
| 83 |
+
mensagem["Subject"] = "Analytical-Force — Teste Gmail API"
|
| 84 |
+
mensagem["From"] = SENDER
|
| 85 |
+
mensagem["To"] = RECIPIENT
|
| 86 |
+
raw = base64.urlsafe_b64encode(mensagem.as_bytes()).decode("utf-8")
|
| 87 |
+
|
| 88 |
+
resp = requests.post(
|
| 89 |
+
"https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
|
| 90 |
+
headers={"Authorization": f"Bearer {token}"},
|
| 91 |
+
json={"raw": raw},
|
| 92 |
+
timeout=30,
|
| 93 |
+
)
|
| 94 |
+
if resp.status_code >= 400:
|
| 95 |
+
print("Falha ao enviar. Status:", resp.status_code, "-", resp.text[:300])
|
| 96 |
+
raise SystemExit(1)
|
| 97 |
+
print(f"E-mail de teste enviado com sucesso para {RECIPIENT}.")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
main()
|
src/config/settings.py
CHANGED
|
@@ -251,12 +251,33 @@ class EmailSettings:
|
|
| 251 |
smtp_user: str = ""
|
| 252 |
smtp_password: str = ""
|
| 253 |
recipient_email: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
@property
|
| 256 |
-
def
|
| 257 |
-
"""Indica se há
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
return bool(self.smtp_host and self.smtp_port and self.recipient_email)
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
@dataclass(frozen=True)
|
| 262 |
class ClickUpSettings:
|
|
@@ -592,6 +613,10 @@ def get_settings() -> Settings:
|
|
| 592 |
smtp_user=_get_str("SMTP_USER"),
|
| 593 |
smtp_password=_get_str("SMTP_PASSWORD"),
|
| 594 |
recipient_email=_get_str("REPORT_RECIPIENT_EMAIL"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
)
|
| 596 |
|
| 597 |
clickup = ClickUpSettings(
|
|
|
|
| 251 |
smtp_user: str = ""
|
| 252 |
smtp_password: str = ""
|
| 253 |
recipient_email: str = ""
|
| 254 |
+
# Gmail API (HTTP/OAuth) — envia por HTTPS, funcionando onde o SMTP é
|
| 255 |
+
# bloqueado (ex.: Hugging Face Spaces). Preferido quando configurado.
|
| 256 |
+
gmail_client_id: str = ""
|
| 257 |
+
gmail_client_secret: str = ""
|
| 258 |
+
gmail_refresh_token: str = ""
|
| 259 |
+
gmail_sender: str = ""
|
| 260 |
|
| 261 |
@property
|
| 262 |
+
def gmail_api_configured(self) -> bool:
|
| 263 |
+
"""Indica se há credenciais para enviar via Gmail API (HTTP)."""
|
| 264 |
+
return bool(
|
| 265 |
+
self.gmail_client_id
|
| 266 |
+
and self.gmail_client_secret
|
| 267 |
+
and self.gmail_refresh_token
|
| 268 |
+
and self.recipient_email
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
@property
|
| 272 |
+
def smtp_configured(self) -> bool:
|
| 273 |
+
"""Indica se há dados mínimos para enviar via SMTP."""
|
| 274 |
return bool(self.smtp_host and self.smtp_port and self.recipient_email)
|
| 275 |
|
| 276 |
+
@property
|
| 277 |
+
def is_configured(self) -> bool:
|
| 278 |
+
"""Indica se há ALGUM método de envio configurado (Gmail API ou SMTP)."""
|
| 279 |
+
return self.gmail_api_configured or self.smtp_configured
|
| 280 |
+
|
| 281 |
|
| 282 |
@dataclass(frozen=True)
|
| 283 |
class ClickUpSettings:
|
|
|
|
| 613 |
smtp_user=_get_str("SMTP_USER"),
|
| 614 |
smtp_password=_get_str("SMTP_PASSWORD"),
|
| 615 |
recipient_email=_get_str("REPORT_RECIPIENT_EMAIL"),
|
| 616 |
+
gmail_client_id=_get_str("GMAIL_CLIENT_ID"),
|
| 617 |
+
gmail_client_secret=_get_str("GMAIL_CLIENT_SECRET"),
|
| 618 |
+
gmail_refresh_token=_get_str("GMAIL_REFRESH_TOKEN"),
|
| 619 |
+
gmail_sender=_get_str("GMAIL_SENDER"),
|
| 620 |
)
|
| 621 |
|
| 622 |
clickup = ClickUpSettings(
|
src/delivery/clickup_sender.py
CHANGED
|
@@ -34,6 +34,25 @@ def _link_salesforce(instance_url: str | None, record_id: str | None) -> str | N
|
|
| 34 |
return f"{instance_url.rstrip('/')}/{record_id}"
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def _moeda(valor: Any) -> str:
|
| 38 |
"""Formata um número como moeda em Real (R$ 1.234,56)."""
|
| 39 |
try:
|
|
@@ -211,11 +230,13 @@ def criar_tarefas_de_alertas(
|
|
| 211 |
|
| 212 |
for alerta in criticos:
|
| 213 |
severidade = alerta.get("severity", "high")
|
|
|
|
| 214 |
corpo: dict[str, Any] = {
|
| 215 |
"name": f"[Analytical-Force] {alerta.get('title', 'Alerta crítico')}",
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
),
|
|
|
|
| 219 |
"priority": _PRIORIDADE_SEVERIDADE.get(severidade, 1),
|
| 220 |
}
|
| 221 |
if assignees:
|
|
@@ -232,7 +253,8 @@ def criar_tarefas_de_alertas(
|
|
| 232 |
except Exception: # resposta sem JSON não deve quebrar o fluxo
|
| 233 |
pass
|
| 234 |
except Exception as exc: # não derruba o agente
|
| 235 |
-
|
|
|
|
| 236 |
|
| 237 |
logger.info("Tarefas criadas no ClickUp: %d.", criadas)
|
| 238 |
return criadas
|
|
|
|
| 34 |
return f"{instance_url.rstrip('/')}/{record_id}"
|
| 35 |
|
| 36 |
|
| 37 |
+
def _detalhe_erro_clickup(exc: Exception) -> str:
|
| 38 |
+
"""Extrai status + mensagem do erro do ClickUp, para diagnóstico (sem segredos).
|
| 39 |
+
|
| 40 |
+
A resposta de erro do ClickUp traz campos como ``err`` e ``ECODE`` — úteis
|
| 41 |
+
para entender a recusa (token, lista, assignee, etc.).
|
| 42 |
+
"""
|
| 43 |
+
resp = getattr(exc, "response", None)
|
| 44 |
+
if resp is not None:
|
| 45 |
+
try:
|
| 46 |
+
corpo = resp.json()
|
| 47 |
+
msg = corpo.get("err") or corpo.get("error") or ""
|
| 48 |
+
ecode = corpo.get("ECODE", "")
|
| 49 |
+
return f"HTTP {resp.status_code} {ecode}: {msg}".strip()
|
| 50 |
+
except Exception:
|
| 51 |
+
texto = str(getattr(resp, "text", ""))[:300]
|
| 52 |
+
return f"HTTP {getattr(resp, 'status_code', '?')}: {texto}"
|
| 53 |
+
return f"{type(exc).__name__}: {exc}"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
def _moeda(valor: Any) -> str:
|
| 57 |
"""Formata um número como moeda em Real (R$ 1.234,56)."""
|
| 58 |
try:
|
|
|
|
| 230 |
|
| 231 |
for alerta in criticos:
|
| 232 |
severidade = alerta.get("severity", "high")
|
| 233 |
+
markdown = _descricao_markdown(alerta, instance_url, report_date)
|
| 234 |
corpo: dict[str, Any] = {
|
| 235 |
"name": f"[Analytical-Force] {alerta.get('title', 'Alerta crítico')}",
|
| 236 |
+
# ClickUp usa 'markdown_content' para a descrição em Markdown;
|
| 237 |
+
# mantemos 'description' como texto simples de fallback.
|
| 238 |
+
"description": str(alerta.get("description") or alerta.get("title", "")),
|
| 239 |
+
"markdown_content": markdown,
|
| 240 |
"priority": _PRIORIDADE_SEVERIDADE.get(severidade, 1),
|
| 241 |
}
|
| 242 |
if assignees:
|
|
|
|
| 253 |
except Exception: # resposta sem JSON não deve quebrar o fluxo
|
| 254 |
pass
|
| 255 |
except Exception as exc: # não derruba o agente
|
| 256 |
+
# Surface do motivo real do ClickUp (status + mensagem), sem segredos.
|
| 257 |
+
logger.error("Falha ao criar tarefa no ClickUp: %s", _detalhe_erro_clickup(exc))
|
| 258 |
|
| 259 |
logger.info("Tarefas criadas no ClickUp: %d.", criadas)
|
| 260 |
return criadas
|
src/delivery/email_sender.py
CHANGED
|
@@ -8,11 +8,14 @@ retorna ``False``.
|
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
|
|
| 11 |
import smtplib
|
| 12 |
from email.mime.multipart import MIMEMultipart
|
| 13 |
from email.mime.text import MIMEText
|
| 14 |
from typing import Any
|
| 15 |
|
|
|
|
|
|
|
| 16 |
from ..config.settings import EmailSettings
|
| 17 |
from ..utils.logger import get_logger
|
| 18 |
|
|
@@ -265,10 +268,13 @@ def enviar_relatorio_email(
|
|
| 265 |
metrics: dict[str, Any],
|
| 266 |
alerts: list[dict[str, Any]],
|
| 267 |
) -> bool:
|
| 268 |
-
"""Envia o relatório executivo por e-mail
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
Args:
|
| 271 |
-
config: Configurações de
|
| 272 |
assunto: Assunto do e-mail.
|
| 273 |
report_date: Data de referência do relatório (texto).
|
| 274 |
metrics: Métricas calculadas (leads/opportunities/tasks/...).
|
|
@@ -278,19 +284,28 @@ def enviar_relatorio_email(
|
|
| 278 |
``True`` se enviado; ``False`` se não configurado ou em caso de erro.
|
| 279 |
"""
|
| 280 |
if not config.is_configured:
|
| 281 |
-
logger.info("Envio de e-mail ignorado:
|
| 282 |
return False
|
| 283 |
|
| 284 |
html = _montar_html(report_date, metrics, alerts)
|
| 285 |
texto = _montar_texto(report_date, metrics, alerts)
|
| 286 |
|
|
|
|
| 287 |
mensagem = MIMEMultipart("alternative")
|
| 288 |
mensagem["Subject"] = assunto
|
| 289 |
-
mensagem["From"] =
|
| 290 |
mensagem["To"] = config.recipient_email
|
| 291 |
mensagem.attach(MIMEText(texto, "plain", "utf-8"))
|
| 292 |
mensagem.attach(MIMEText(html, "html", "utf-8"))
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
try:
|
| 295 |
with smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=30) as servidor:
|
| 296 |
servidor.starttls()
|
|
@@ -299,9 +314,57 @@ def enviar_relatorio_email(
|
|
| 299 |
servidor.sendmail(
|
| 300 |
mensagem["From"], [config.recipient_email], mensagem.as_string()
|
| 301 |
)
|
| 302 |
-
logger.info("Relatório enviado por e-mail para %s.", config.recipient_email)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
return True
|
| 304 |
except Exception as exc: # não deve derrubar o agente
|
| 305 |
-
# Surface do tipo de erro
|
| 306 |
-
logger.error("Falha ao enviar e-mail: %s", type(exc).__name__)
|
| 307 |
return False
|
|
|
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
+
import base64
|
| 12 |
import smtplib
|
| 13 |
from email.mime.multipart import MIMEMultipart
|
| 14 |
from email.mime.text import MIMEText
|
| 15 |
from typing import Any
|
| 16 |
|
| 17 |
+
import requests
|
| 18 |
+
|
| 19 |
from ..config.settings import EmailSettings
|
| 20 |
from ..utils.logger import get_logger
|
| 21 |
|
|
|
|
| 268 |
metrics: dict[str, Any],
|
| 269 |
alerts: list[dict[str, Any]],
|
| 270 |
) -> bool:
|
| 271 |
+
"""Envia o relatório executivo por e-mail.
|
| 272 |
+
|
| 273 |
+
Prefere a **Gmail API (HTTP)** quando configurada — funciona em ambientes
|
| 274 |
+
que bloqueiam SMTP (ex.: Hugging Face Spaces). Caso contrário, usa SMTP.
|
| 275 |
|
| 276 |
Args:
|
| 277 |
+
config: Configurações de e-mail (Gmail API e/ou SMTP).
|
| 278 |
assunto: Assunto do e-mail.
|
| 279 |
report_date: Data de referência do relatório (texto).
|
| 280 |
metrics: Métricas calculadas (leads/opportunities/tasks/...).
|
|
|
|
| 284 |
``True`` se enviado; ``False`` se não configurado ou em caso de erro.
|
| 285 |
"""
|
| 286 |
if not config.is_configured:
|
| 287 |
+
logger.info("Envio de e-mail ignorado: nenhum método configurado.")
|
| 288 |
return False
|
| 289 |
|
| 290 |
html = _montar_html(report_date, metrics, alerts)
|
| 291 |
texto = _montar_texto(report_date, metrics, alerts)
|
| 292 |
|
| 293 |
+
remetente = config.gmail_sender or config.smtp_user or "analytical-force@localhost"
|
| 294 |
mensagem = MIMEMultipart("alternative")
|
| 295 |
mensagem["Subject"] = assunto
|
| 296 |
+
mensagem["From"] = remetente
|
| 297 |
mensagem["To"] = config.recipient_email
|
| 298 |
mensagem.attach(MIMEText(texto, "plain", "utf-8"))
|
| 299 |
mensagem.attach(MIMEText(html, "html", "utf-8"))
|
| 300 |
|
| 301 |
+
# Gmail API tem prioridade (HTTPS; passa por firewalls que bloqueiam SMTP).
|
| 302 |
+
if config.gmail_api_configured:
|
| 303 |
+
return _enviar_via_gmail_api(config, mensagem)
|
| 304 |
+
return _enviar_via_smtp(config, mensagem)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _enviar_via_smtp(config: EmailSettings, mensagem: MIMEMultipart) -> bool:
|
| 308 |
+
"""Envia a mensagem via SMTP (STARTTLS). Usado em ambiente local."""
|
| 309 |
try:
|
| 310 |
with smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=30) as servidor:
|
| 311 |
servidor.starttls()
|
|
|
|
| 314 |
servidor.sendmail(
|
| 315 |
mensagem["From"], [config.recipient_email], mensagem.as_string()
|
| 316 |
)
|
| 317 |
+
logger.info("Relatório enviado por e-mail (SMTP) para %s.", config.recipient_email)
|
| 318 |
+
return True
|
| 319 |
+
except Exception as exc: # não deve derrubar o agente
|
| 320 |
+
# Surface do tipo de erro (sem expor senha). OSError costuma indicar
|
| 321 |
+
# SMTP bloqueado pelo ambiente (ex.: Hugging Face) — use a Gmail API.
|
| 322 |
+
logger.error("Falha ao enviar e-mail (SMTP): %s", type(exc).__name__)
|
| 323 |
+
return False
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def _obter_access_token_gmail(config: EmailSettings) -> str:
|
| 327 |
+
"""Obtém um access_token do Google via refresh_token (OAuth).
|
| 328 |
+
|
| 329 |
+
Não registra o token nem o payload (contêm segredos).
|
| 330 |
+
"""
|
| 331 |
+
resposta = requests.post(
|
| 332 |
+
"https://oauth2.googleapis.com/token",
|
| 333 |
+
data={
|
| 334 |
+
"grant_type": "refresh_token",
|
| 335 |
+
"client_id": config.gmail_client_id,
|
| 336 |
+
"client_secret": config.gmail_client_secret,
|
| 337 |
+
"refresh_token": config.gmail_refresh_token,
|
| 338 |
+
},
|
| 339 |
+
timeout=30,
|
| 340 |
+
)
|
| 341 |
+
if resposta.status_code != 200:
|
| 342 |
+
try:
|
| 343 |
+
erro = resposta.json()
|
| 344 |
+
detalhe = f"{erro.get('error')} - {erro.get('error_description', '')}".strip(" -")
|
| 345 |
+
except ValueError:
|
| 346 |
+
detalhe = "resposta inválida do servidor OAuth do Google."
|
| 347 |
+
raise RuntimeError(f"OAuth Google falhou (status {resposta.status_code}): {detalhe}")
|
| 348 |
+
return resposta.json()["access_token"]
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def _enviar_via_gmail_api(config: EmailSettings, mensagem: MIMEMultipart) -> bool:
|
| 352 |
+
"""Envia a mensagem usando a Gmail API (HTTP), via OAuth refresh token."""
|
| 353 |
+
try:
|
| 354 |
+
token = _obter_access_token_gmail(config)
|
| 355 |
+
raw = base64.urlsafe_b64encode(mensagem.as_bytes()).decode("utf-8")
|
| 356 |
+
resposta = requests.post(
|
| 357 |
+
"https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
|
| 358 |
+
headers={"Authorization": f"Bearer {token}"},
|
| 359 |
+
json={"raw": raw},
|
| 360 |
+
timeout=30,
|
| 361 |
+
)
|
| 362 |
+
resposta.raise_for_status()
|
| 363 |
+
logger.info(
|
| 364 |
+
"Relatório enviado por e-mail (Gmail API) para %s.", config.recipient_email
|
| 365 |
+
)
|
| 366 |
return True
|
| 367 |
except Exception as exc: # não deve derrubar o agente
|
| 368 |
+
# Surface do tipo de erro, sem expor token.
|
| 369 |
+
logger.error("Falha ao enviar e-mail (Gmail API): %s", type(exc).__name__)
|
| 370 |
return False
|