@danhachuel/prodmaker 0.1.14 → 0.1.16

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,43 @@
3
3
  Todas as versões seguem a etiqueta visual `MAJOR.MINOR.PATCH` com patch de dois dígitos
4
4
  (o registry NPM normaliza `0.1.00` → `0.1.0`).
5
5
 
6
+ ## 0.1.16 — 2026-10-31
7
+
8
+ - **Fase 12 — Billing Stripe + E-mail Marketing (AdsAcademy) + Supabase multi-tenant pronto.**
9
+ - **Billing:** `seed/planos.json` (Local grátis / Lançador PRO R$97 / Escala AGENCY R$297),
10
+ página **Plano & Assinatura** com cards, e-mail de conta e botão Assinar (abre o
11
+ Checkout do Stripe em nova aba); `POST /api/billing/checkout`, `/api/billing/assinatura`,
12
+ `/api/billing/historico` e **webhook público** `/api/billing/webhook` com verificação
13
+ de assinatura HMAC-SHA256 (t=...,v1=...) processando checkout.session.completed,
14
+ invoice.paid e customer.subscription.deleted. Sem chaves Stripe o app segue 100%
15
+ liberado em modo local (nenhuma trava de pré-registo).
16
+ - **E-mail Marketing (AdsAcademy):** sequências com gatilho (`venda_recebida` dispara
17
+ automaticamente a cada webhook de venda, `produto_salvo`, `manual`), variáveis
18
+ {{nome}}/{{produto}}/{{valor}}/{{link}}, envio pelo apprise já integrado (mailtos://,
19
+ tgram://...), editor de sequências, envio manual e histórico com status/erro. A
20
+ plataforma completa emareach fica como backlog da nuvem.
21
+ - **Supabase multi-tenant:** `DATABASE_URL` agora também aceita o alias **`SUPABASE_DB_URL`**
22
+ (connection string do Supabase) mantendo SQLite como fallback — user_id já cobre as
23
+ 15 tabelas; a ativação do multi-tenant depende apenas do Supabase Auth (backlog).
24
+ - Novas tabelas: `assinaturas`, `email_sequencias`, `email_envios`.
25
+
26
+ ## 0.1.15 — 2026-10-31
27
+
28
+ - **Fase 11 — TikTok Ads real + Anti-Fraude AdsPixel (Redis).**
29
+ - **TikTok Ads (publicação automática):** `publish_tiktok.py` — troca do auth_code por
30
+ access_token/refresh_token (`POST /api/tiktok/token`) e criação de campanha + grupo
31
+ PAUSADOS via Marketing API v1.3 (objective CONVERSIONS, budget diário US$,
32
+ placement automático); passo final do criativo com instrução honesta quando não há
33
+ mídia. Erros da API atribuídos. Card TikTok em Chaves de API ganhou auth_code +
34
+ botão "Trocar auth_code por token". AdsCreator passa a publicar TikTok de verdade.
35
+ - **Anti-Fraude (AdsPixel):** `fraude.py` seguindo o padrão do redis-datasets/fraud-detection —
36
+ **IP blacklist (Redis Cuckoo Filter)** + **click spamming (Sorted Set com janela de 60s e
37
+ limite de 10 cliques)**; **fallback SQLite automático sem Redis**; página **Anti-Fraude**
38
+ (/tracking/fraude) com status do motor, bloqueio/desbloqueio manual, cópia da lista e
39
+ métricas de spam 24h. O `/track` agora avalia IP bloqueado antes de gravar e o pixel
40
+ nunca quebra a página do visitante. Card **Redis** em Chaves de API (REDIS_URL).
41
+ - OAuth Google Ads (item 1 da Fase 11) já estava entregue na 0.1.12.
42
+
6
43
  ## 0.1.14 — 2026-10-31
7
44
 
8
45
  - **Fase 10 — REVISÃO (fechamento das lacunas apontadas na criação de produto/PDF).**
package/app/api/ads.py CHANGED
@@ -143,11 +143,9 @@ def campanhas_publicar(campanha_id: int):
143
143
  elif plataforma == "meta":
144
144
  resultado = publish_ads.publicar_meta(campanha)
145
145
  else:
146
- resultado = {
147
- "plataforma": "tiktok",
148
- "etapas": [{"etapa": "manual", "observacao": "TikTok: siga o checklist do plano (formato 9:16 UGC) e crie manualmente no TikTok Ads Manager."}],
149
- "observacao": "Plano manual — crie manualmente no TikTok Ads Manager.",
150
- }
146
+ from ..services import publish_tiktok
147
+
148
+ resultado = publish_tiktok.publicar_campanha(campanha)
151
149
  except LLMError as exc:
152
150
  campaigns.marcar_publicada(campanha_id, {}, status="erro", erro=str(exc))
153
151
  raise HTTPException(status_code=400, detail=str(exc))
@@ -0,0 +1,63 @@
1
+ """Billing — planos (seed), checkout Stripe, webhook público e status da assinatura."""
2
+ from fastapi import APIRouter, HTTPException, Request
3
+ from fastapi.responses import Response
4
+ from pydantic import BaseModel
5
+
6
+ from ..services import stripe_billing
7
+ from ..services.llm import LLMError
8
+
9
+ router = APIRouter(prefix="/api/billing", tags=["billing"])
10
+
11
+
12
+ class CheckoutBody(BaseModel):
13
+ plano_id: str
14
+ email: str
15
+
16
+
17
+ @router.get("/planos")
18
+ def planos():
19
+ return stripe_billing.planos_local()
20
+
21
+
22
+ @router.get("/assinatura")
23
+ def assinatura():
24
+ return stripe_billing.assinatura_atual()
25
+
26
+
27
+ @router.get("/historico")
28
+ def historico():
29
+ return stripe_billing.historico()
30
+
31
+
32
+ @router.post("/checkout")
33
+ def checkout(body: CheckoutBody, request: Request):
34
+ base = f"{request.url.scheme}://{request.headers.get('host', 'localhost:8188')}"
35
+ try:
36
+ return stripe_billing.criar_checkout(
37
+ body.plano_id,
38
+ body.email,
39
+ f"{base}/configuracoes?checkout=ok",
40
+ f"{base}/configuracoes?checkout=cancelado",
41
+ )
42
+ except LLMError as exc:
43
+ raise HTTPException(status_code=400, detail=str(exc))
44
+
45
+
46
+ @router.options("/webhook")
47
+ def webhook_options():
48
+ return Response(status_code=204)
49
+
50
+
51
+ @router.post("/webhook")
52
+ async def webhook_stripe(request: Request):
53
+ """Webhook público do Stripe — verifica assinatura e processa o evento."""
54
+ body = await request.body()
55
+ if not stripe_billing.verificar_assinatura(body, request.headers.get("stripe-signature", "")):
56
+ raise HTTPException(status_code=400, detail="Assinatura do webhook inválida.")
57
+ import json
58
+
59
+ try:
60
+ evento = json.loads(body)
61
+ except json.JSONDecodeError:
62
+ raise HTTPException(status_code=400, detail="Payload inválido.")
63
+ return stripe_billing.processar_webhook(evento)
@@ -0,0 +1,65 @@
1
+ """E-mail Marketing (AdsAcademy) — sequências com gatilho, envio manual e histórico."""
2
+ from fastapi import APIRouter, HTTPException
3
+ from pydantic import BaseModel
4
+
5
+ from ..services import email_mkt
6
+ from ..services.llm import LLMError
7
+
8
+ router = APIRouter(prefix="/api/email", tags=["email"])
9
+
10
+
11
+ class SequenciaBody(BaseModel):
12
+ nome: str
13
+ gatilho: str
14
+ assunto: str
15
+ corpo: str
16
+ ativo: bool = True
17
+
18
+
19
+ class EnviarBody(BaseModel):
20
+ sequencia_id: int
21
+ destinos: list = []
22
+ variaveis: dict = {}
23
+
24
+
25
+ @router.get("/sequencias")
26
+ def sequencias():
27
+ return email_mkt.listar()
28
+
29
+
30
+ @router.post("/sequencias")
31
+ def criar(body: SequenciaBody):
32
+ try:
33
+ return email_mkt.salvar(body.nome, body.gatilho, body.assunto, body.corpo, body.ativo)
34
+ except LLMError as exc:
35
+ raise HTTPException(status_code=400, detail=str(exc))
36
+
37
+
38
+ @router.put("/sequencias/{sequencia_id}")
39
+ def atualizar(sequencia_id: int, body: SequenciaBody):
40
+ try:
41
+ return email_mkt.salvar(body.nome, body.gatilho, body.assunto, body.corpo, body.ativo, sequencia_id)
42
+ except LLMError as exc:
43
+ raise HTTPException(status_code=400, detail=str(exc))
44
+
45
+
46
+ @router.delete("/sequencias/{sequencia_id}")
47
+ def remover(sequencia_id: int):
48
+ try:
49
+ email_mkt.remover(sequencia_id)
50
+ except LLMError as exc:
51
+ raise HTTPException(status_code=404, detail=str(exc))
52
+ return {"ok": True}
53
+
54
+
55
+ @router.post("/enviar")
56
+ def enviar(body: EnviarBody):
57
+ try:
58
+ return email_mkt.enviar_manual(body.sequencia_id, body.destinos, body.variaveis)
59
+ except LLMError as exc:
60
+ raise HTTPException(status_code=400, detail=str(exc))
61
+
62
+
63
+ @router.get("/historico")
64
+ def historico():
65
+ return email_mkt.historico()
@@ -0,0 +1,35 @@
1
+ """Anti-Fraude (AdsPixel) — painel de IPs bloqueados, bloqueio/desbloqueio e resumo."""
2
+ from fastapi import APIRouter, HTTPException
3
+ from pydantic import BaseModel
4
+
5
+ from ..services import fraude
6
+ from ..services.llm import LLMError
7
+
8
+ router = APIRouter(prefix="/api/fraude", tags=["anti-fraude"])
9
+
10
+
11
+ class BloquearBody(BaseModel):
12
+ ip: str
13
+ motivo: str = "manual"
14
+
15
+
16
+ @router.get("/resumo")
17
+ def resumo():
18
+ return fraude.resumo()
19
+
20
+
21
+ @router.post("/bloquear")
22
+ def bloquear(body: BloquearBody):
23
+ try:
24
+ return fraude.bloquear_ip(body.ip, body.motivo)
25
+ except LLMError as exc:
26
+ raise HTTPException(status_code=400, detail=str(exc))
27
+
28
+
29
+ @router.delete("/ips/{ip}")
30
+ def desbloquear(ip: str):
31
+ try:
32
+ fraude.desbloquear_ip(ip)
33
+ except LLMError as exc:
34
+ raise HTTPException(status_code=404, detail=str(exc))
35
+ return {"ok": True}
@@ -0,0 +1,16 @@
1
+ """TikTok Ads — troca do auth_code por access_token (1 clique)."""
2
+ from fastapi import APIRouter, HTTPException
3
+
4
+ from ..services.llm import LLMError
5
+ from ..services.publish_tiktok import trocar_auth_code
6
+
7
+ router = APIRouter(prefix="/api/tiktok", tags=["tiktok"])
8
+
9
+
10
+ @router.post("/token")
11
+ def token():
12
+ """Troca o auth_code salvo em Chaves de API por access_token + refresh_token."""
13
+ try:
14
+ return trocar_auth_code()
15
+ except LLMError as exc:
16
+ raise HTTPException(status_code=400, detail=str(exc))
package/app/main.py CHANGED
@@ -15,7 +15,7 @@ from .services.database import ensure_db, get_db, now_str, row_to_dict
15
15
  from .services.llm import LLMError, analyze_product, list_providers
16
16
  from .services.store import PRODMAKER_HOME, STORAGE, STATE, ensure_state_dir, get_master_code, get_settings, save_settings
17
17
 
18
- APP_VERSION = "0.1.14"
18
+ APP_VERSION = "0.1.16"
19
19
  COOKIE_NAME = "prodmaker_session"
20
20
  SESSION_DAYS = 30
21
21
  MASTER_ALIAS = "admin"
@@ -41,6 +41,10 @@ from .api import guias as guias_router # noqa: E402
41
41
  from .api import contas as contas_router # noqa: E402
42
42
  from .api import invest as invest_router # noqa: E402
43
43
  from .api import builder as builder_router # noqa: E402
44
+ from .api import fraude as fraude_router # noqa: E402
45
+ from .api import tiktok as tiktok_router # noqa: E402
46
+ from .api import billing as billing_router # noqa: E402
47
+ from .api import email_mkt as email_mkt_router # noqa: E402
44
48
 
45
49
  app.include_router(llm_router.router)
46
50
  app.include_router(infoprodutos_router.router)
@@ -58,6 +62,10 @@ app.include_router(guias_router.router)
58
62
  app.include_router(contas_router.router)
59
63
  app.include_router(invest_router.router)
60
64
  app.include_router(builder_router.router)
65
+ app.include_router(fraude_router.router)
66
+ app.include_router(tiktok_router.router)
67
+ app.include_router(billing_router.router)
68
+ app.include_router(email_mkt_router.router)
61
69
 
62
70
  ensure_db()
63
71
 
@@ -264,7 +272,7 @@ def health():
264
272
 
265
273
  @app.get("/api/version")
266
274
  def version():
267
- return {"version": APP_VERSION, "canonical": "0.1.14"}
275
+ return {"version": APP_VERSION, "canonical": "0.1.16"}
268
276
 
269
277
 
270
278
  @app.get("/api/settings")
@@ -300,6 +308,20 @@ def track(request: Request):
300
308
  forwarded = request.headers.get("x-forwarded-for", "")
301
309
  ip = forwarded.split(",")[0].strip() or (request.client.host if request.client else "")
302
310
  known = {"event", "page", "gclid", "utm_source", "utm_medium", "utm_campaign", "ref"}
311
+
312
+ # Anti-fraude (AdsPixel): IP bloqueado nao entra no tracking; spam e sinalizado
313
+ from .services import fraude
314
+
315
+ try:
316
+ avaliacao = fraude.avaliar_visita(ip)
317
+ if avaliacao.get("bloqueado"):
318
+ # IP na blacklist: nao grava a visita (o pixel segue respondendo 204)
319
+ return Response(status_code=204, headers={"Access-Control-Allow-Origin": "*"})
320
+ if params.get("event") == "cta_click":
321
+ fraude.registrar_clique(ip, params.get("page", ""))
322
+ except Exception:
323
+ pass # anti-fraude nunca quebra o pixel
324
+ extra_params = json.dumps({k: v for k, v in params.items() if k not in known}, ensure_ascii=False)
303
325
  try:
304
326
  with get_db() as conn:
305
327
  conn.execute(
@@ -314,7 +336,7 @@ def track(request: Request):
314
336
  params.get("ref", ""),
315
337
  ip,
316
338
  request.headers.get("user-agent", ""),
317
- json.dumps({k: v for k, v in params.items() if k not in known}, ensure_ascii=False),
339
+ extra_params,
318
340
  now_str(),
319
341
  ),
320
342
  )
@@ -1,17 +1,17 @@
1
- """Banco de dados — façade sobre dbengine (SQLite padrão, PostgreSQL/Supabase via DATABASE_URL).
1
+ """Banco de dados — fachada sobre dbengine (SQLite padrao, PostgreSQL/Supabase via DATABASE_URL).
2
2
 
3
- Esquema único para os dois engines. Todas as tabelas carregam `user_id` (preparação
4
- multi-tenant — hoje vazio). `ensure_db()` cria o que falta e aplica ALTERs de migração.
3
+ Esquema unico para os dois engines. Todas as tabelas carregam `user_id` (preparacao
4
+ multi-tenant — hoje vazio). `ensure_db()` cria o que falta e aplica ALTERs de migracao.
5
5
  """
6
6
  from .dbengine import (
7
7
  add_column_if_missing,
8
8
  is_postgres,
9
- now_str, # noqa: F401 (reexportado para os serviços)
10
- row_to_dict, # noqa: F401 (reexportado para os serviços)
9
+ now_str, # noqa: F401 (reexportado para os servicos)
10
+ row_to_dict, # noqa: F401 (reexportado para os servicos)
11
11
  )
12
12
 
13
13
  # ---------------------------------------------------------------------------
14
- # Esquema — SQLite DDL; para PostgreSQL as mesmas definições são traduzidas.
14
+ # Esquema — SQLite DDL; para PostgreSQL as mesmas definicoes sao traduzidas.
15
15
  # ---------------------------------------------------------------------------
16
16
 
17
17
  TABLES = {
@@ -201,6 +201,62 @@ TABLES = {
201
201
  created_at TEXT NOT NULL
202
202
  )
203
203
  """,
204
+ "ips_bloqueados": """
205
+ CREATE TABLE IF NOT EXISTS ips_bloqueados (
206
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
207
+ user_id TEXT NOT NULL DEFAULT '',
208
+ ip TEXT NOT NULL,
209
+ motivo TEXT NOT NULL DEFAULT 'manual',
210
+ origem TEXT NOT NULL DEFAULT 'sqlite',
211
+ created_at TEXT NOT NULL
212
+ )
213
+ """,
214
+ "fraude_eventos": """
215
+ CREATE TABLE IF NOT EXISTS fraude_eventos (
216
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
217
+ user_id TEXT NOT NULL DEFAULT '',
218
+ ip TEXT NOT NULL,
219
+ page TEXT NOT NULL DEFAULT '',
220
+ created_epoch REAL NOT NULL DEFAULT 0,
221
+ created_at TEXT NOT NULL
222
+ )
223
+ """,
224
+ "assinaturas": """
225
+ CREATE TABLE IF NOT EXISTS assinaturas (
226
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
227
+ user_id TEXT NOT NULL DEFAULT '',
228
+ plano TEXT NOT NULL DEFAULT '',
229
+ email TEXT NOT NULL DEFAULT '',
230
+ stripe_ref TEXT NOT NULL DEFAULT '',
231
+ status TEXT NOT NULL DEFAULT 'checkout',
232
+ current_period_end TEXT NOT NULL DEFAULT '',
233
+ created_at TEXT NOT NULL
234
+ )
235
+ """,
236
+ "email_sequencias": """
237
+ CREATE TABLE IF NOT EXISTS email_sequencias (
238
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
239
+ user_id TEXT NOT NULL DEFAULT '',
240
+ nome TEXT NOT NULL,
241
+ gatilho TEXT NOT NULL DEFAULT 'manual',
242
+ assunto TEXT NOT NULL,
243
+ corpo TEXT NOT NULL,
244
+ ativo INTEGER NOT NULL DEFAULT 1,
245
+ created_at TEXT NOT NULL
246
+ )
247
+ """,
248
+ "email_envios": """
249
+ CREATE TABLE IF NOT EXISTS email_envios (
250
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
251
+ user_id TEXT NOT NULL DEFAULT '',
252
+ sequencia_id INTEGER NOT NULL DEFAULT 0,
253
+ destino TEXT NOT NULL DEFAULT '',
254
+ assunto TEXT NOT NULL DEFAULT '',
255
+ status TEXT NOT NULL DEFAULT 'pendente',
256
+ erro TEXT NOT NULL DEFAULT '',
257
+ created_at TEXT NOT NULL
258
+ )
259
+ """,
204
260
  }
205
261
 
206
262
  ALL_TABLES = list(TABLES.keys())
@@ -225,11 +281,9 @@ def ensure_db() -> None:
225
281
  else:
226
282
  for ddl in TABLES.values():
227
283
  conn.execute(ddl)
228
- # Migração: bancos criados antes da coluna user_id / da tabela investimentos
284
+ # Migracao: bancos criados antes da coluna user_id / da tabela investimentos
229
285
  for table in ALL_TABLES:
230
286
  add_column_if_missing(conn, table, "user_id TEXT NOT NULL DEFAULT ''", "user_id")
231
- if "investimentos" not in [row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]:
232
- conn.execute(TABLES["investimentos"])
233
287
  conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_gclid ON vendas (gclid)")
234
288
  conn.execute("CREATE INDEX IF NOT EXISTS idx_visitas_gclid ON visitas (gclid)")
235
289
 
@@ -31,7 +31,8 @@ def row_to_dict(row) -> dict:
31
31
 
32
32
 
33
33
  def dsn() -> str | None:
34
- return (os.environ.get("DATABASE_URL") or "").strip() or None
34
+ """Connection string do PostgreSQL — aceita DATABASE_URL ou SUPABASE_DB_URL."""
35
+ return (os.environ.get("DATABASE_URL") or os.environ.get("SUPABASE_DB_URL") or "").strip() or None
35
36
 
36
37
 
37
38
  def is_postgres() -> bool:
@@ -0,0 +1,146 @@
1
+ """E-mail Marketing / AdsAcademy — sequências transacionais via apprise.
2
+
3
+ Emareach (plataforma completa self-hosted) fica como backlog da nuvem; aqui o
4
+ motor honesto é o apprise já integrado: sequências com gatilho (venda recebida,
5
+ produto salvo, manual), variáveis {{nome}}, {{produto}}, {{valor}}, {{link}} e
6
+ histórico de envios. Sem canais configurados: erro claro, nunca silencioso.
7
+ """
8
+ import re
9
+
10
+ from .database import get_db, now_str, row_to_dict
11
+ from .llm import LLMError
12
+
13
+
14
+ def _render(texto: str, variaveis: dict) -> str:
15
+ for chave, valor in (variaveis or {}).items():
16
+ texto = texto.replace("{{" + chave + "}}", str(valor or ""))
17
+ return texto
18
+
19
+
20
+ def listar(gatilho: str = "") -> list[dict]:
21
+ query = "SELECT * FROM email_sequencias"
22
+ params: tuple = ()
23
+ if gatilho:
24
+ query += " WHERE gatilho = ?"
25
+ params = (gatilho,)
26
+ query += " ORDER BY id DESC"
27
+ with get_db() as conn:
28
+ rows = conn.execute(query, params).fetchall()
29
+ return [row_to_dict(row) for row in rows]
30
+
31
+
32
+ def salvar(nome: str, gatilho: str, assunto: str, corpo: str, ativo: bool = True, sequencia_id: int | None = None) -> dict:
33
+ if not nome.strip() or not assunto.strip() or not corpo.strip():
34
+ raise LLMError("Informe nome, assunto e corpo da sequência.")
35
+ if gatilho not in ("venda_recebida", "produto_salvo", "manual"):
36
+ raise LLMError("Gatilho inválido: use venda_recebida, produto_salvo ou manual.")
37
+ with get_db() as conn:
38
+ if sequencia_id:
39
+ cursor = conn.execute(
40
+ "UPDATE email_sequencias SET nome = ?, gatilho = ?, assunto = ?, corpo = ?, ativo = ? WHERE id = ?",
41
+ (nome.strip(), gatilho, assunto.strip(), corpo, bool(ativo), sequencia_id),
42
+ )
43
+ if cursor.rowcount == 0:
44
+ raise LLMError("Sequência não encontrada.")
45
+ else:
46
+ cursor = conn.execute(
47
+ "INSERT INTO email_sequencias (nome, gatilho, assunto, corpo, ativo, created_at) VALUES (?, ?, ?, ?, ?, ?)",
48
+ (nome.strip(), gatilho, assunto.strip(), corpo, bool(ativo), now_str()),
49
+ )
50
+ sequencia_id = cursor.lastrowid
51
+ row = conn.execute("SELECT * FROM email_sequencias WHERE id = ?", (sequencia_id,)).fetchone()
52
+ return row_to_dict(row)
53
+
54
+
55
+ def remover(sequencia_id: int) -> None:
56
+ with get_db() as conn:
57
+ cursor = conn.execute("DELETE FROM email_sequencias WHERE id = ?", (sequencia_id,))
58
+ if cursor.rowcount == 0:
59
+ raise LLMError("Sequência não encontrada.")
60
+
61
+
62
+ def _variaveis_padrao() -> dict:
63
+ settings_nome = "cliente"
64
+ return {"nome": settings_nome, "produto": "seu produto", "valor": "", "link": ""}
65
+
66
+
67
+ def aplicar_gatilho(gatilho: str, variaveis: dict, destinos: list[str] | None = None) -> dict:
68
+ """Dispara as sequências ativas do gatilho (usado pelos hooks de venda/produto)."""
69
+ enviados = []
70
+ erros = []
71
+ for sequencia in listar(gatilho):
72
+ if not sequencia["ativo"]:
73
+ continue
74
+ assunto = _render(sequencia["assunto"], variaveis)
75
+ corpo = _render(sequencia["corpo"], variaveis)
76
+ try:
77
+ _enviar(sequencia, destinos or [], assunto, corpo)
78
+ enviados.append(sequencia["id"])
79
+ except LLMError as exc:
80
+ _registrar_envio(sequencia["id"], "", assunto, "erro", str(exc))
81
+ erros.append(str(exc))
82
+ return {"enviados": enviados, "erros": erros}
83
+
84
+
85
+ def enviar_manual(sequencia_id: int, destinos: list[str], variaveis: dict) -> dict:
86
+ with get_db() as conn:
87
+ row = conn.execute("SELECT * FROM email_sequencias WHERE id = ?", (sequencia_id,)).fetchone()
88
+ if not row:
89
+ raise LLMError("Sequência não encontrada.")
90
+ sequencia = row_to_dict(row)
91
+ if not destinos:
92
+ raise LLMError("Informe ao menos um destino (URL apprise, ex.: mailtos://user:senha@smtp.com).")
93
+ assunto = _render(sequencia["assunto"], {**_variaveis_padrao(), **(variaveis or {})})
94
+ corpo = _render(sequencia["corpo"], {**_variaveis_padrao(), **(variaveis or {})})
95
+ _enviar(sequencia, destinos, assunto, corpo)
96
+ return {"ok": True, "assunto": assunto}
97
+
98
+
99
+ def _enviar(sequencia: dict, destinos: list, assunto: str, corpo: str) -> None:
100
+ from . import notify
101
+
102
+ alvos = [str(item).strip() for item in (destinos or []) if str(item).strip()]
103
+ if not alvos:
104
+ config = notify.listar_config()
105
+ alvos = [item for item in (config.get("targets") or [])]
106
+ if not alvos:
107
+ erro = "Nenhum canal apprise configurado (settings.notifications.targets)."
108
+ _registrar_envio(sequencia["id"], "", assunto, "erro", erro)
109
+ raise LLMError(erro)
110
+ telegram = notify._telegram_target()
111
+ if telegram and telegram not in alvos:
112
+ alvos.insert(0, telegram)
113
+ try:
114
+ import apprise
115
+
116
+ apobj = apprise.Apprise()
117
+ validos = [alvo for alvo in alvos if apobj.add(alvo)]
118
+ if not validos:
119
+ erro = "Nenhum canal apprise válido."
120
+ _registrar_envio(sequencia["id"], ",".join(alvos), assunto, "erro", erro)
121
+ raise LLMError(erro)
122
+ if apobj.notify(title=assunto, body=corpo):
123
+ _registrar_envio(sequencia["id"], ",".join(validos), assunto, "enviada")
124
+ else:
125
+ erro = "apprise não conseguiu entregar."
126
+ _registrar_envio(sequencia["id"], ",".join(validos), assunto, "erro", erro)
127
+ raise LLMError(erro)
128
+ except ImportError as exc:
129
+ erro = f"apprise ausente ({exc.name}) — rode 'prodmaker install'."
130
+ _registrar_envio(sequencia["id"], "", assunto, "erro", erro)
131
+ raise LLMError(erro)
132
+
133
+
134
+ def _registrar_envio(sequencia_id: int, destino: str, assunto: str, status: str, erro: str = "") -> None:
135
+ with get_db() as conn:
136
+ conn.execute(
137
+ "INSERT INTO email_envios (sequencia_id, destino, assunto, status, erro, created_at) VALUES (?, ?, ?, ?, ?, ?)",
138
+ (sequencia_id, destino, assunto[:180], status, erro[:500], now_str()),
139
+ )
140
+
141
+
142
+ def historico(limit: int = 50) -> dict:
143
+ with get_db() as conn:
144
+ envios = [row_to_dict(row) for row in conn.execute("SELECT * FROM email_envios ORDER BY id DESC LIMIT ?", (limit,))]
145
+ totais = {row["status"]: row["n"] for row in conn.execute("SELECT status, COUNT(*) AS n FROM email_envios GROUP BY status")}
146
+ return {"envios": envios, "totais": totais}