TS431 linhas15.838 bytes
apps/worker/src/routes/newsletter.ts
SHA-256
6cd35aef6162ec4d20da841c584a1113371ffd950e09f03b3bc8d995f4468fa9
Somente leiturafonte-acf635076da2
import { Hono } from 'hono'
import type { Env } from '../types'
import { enviarEdicao } from '../services/newsletter'
export const newsletter = new Hono<{ Bindings: Env }>()
// ============================================================================
// Schema D1 (executado via /admin/migrate)
// CREATE TABLE IF NOT EXISTS newsletter_subscribers (
// id INTEGER PRIMARY KEY AUTOINCREMENT,
// email TEXT NOT NULL UNIQUE,
// status TEXT NOT NULL DEFAULT 'pending', -- pending | active | unsubscribed
// token TEXT NOT NULL,
// created_at TEXT NOT NULL DEFAULT (datetime('now')),
// confirmed_at TEXT,
// unsubscribed_at TEXT,
// ip TEXT,
// user_agent TEXT
// );
// CREATE INDEX IF NOT EXISTS idx_newsletter_status ON newsletter_subscribers(status);
// CREATE INDEX IF NOT EXISTS idx_newsletter_token ON newsletter_subscribers(token);
// ============================================================================
const FROM_EMAIL = 'Observatório do Senado <[email protected]>'
const REPLY_TO = '[email protected]'
const SITE = 'https://observasenado.org'
const API_BASE = 'https://api.observasenado.org'
function isValidEmail(e: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e) && e.length <= 200
}
function generateToken(): string {
const bytes = new Uint8Array(24)
crypto.getRandomValues(bytes)
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
}
async function sendEmailViaResend(
apiKey: string,
to: string,
subject: string,
html: string,
): Promise<{ ok: boolean; error?: string }> {
try {
const r = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: FROM_EMAIL,
to: [to],
reply_to: REPLY_TO,
subject,
html,
}),
})
if (!r.ok) {
const text = await r.text()
return { ok: false, error: `Resend ${r.status}: ${text.slice(0, 200)}` }
}
return { ok: true }
} catch (err) {
return { ok: false, error: String(err) }
}
}
function confirmHtml(token: string): string {
const url = `${API_BASE}/api/newsletter/confirm?token=${token}`
return `
<!doctype html>
<html lang="pt-BR"><body style="font-family:system-ui,Arial,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#1a1a1a;background:#f7f6f3">
<div style="border:1px solid #e2dfd8;background:#ffffff;padding:32px;border-radius:4px">
<h1 style="font-family:Georgia,serif;color:#0F3D5C;margin:0 0 16px;font-size:22px">Confirme sua inscrição</h1>
<p style="line-height:1.6;font-size:15px;color:#1a1a1a">Você solicitou o recebimento da newsletter do <strong>Observatório do Senado</strong>.</p>
<p style="line-height:1.6;font-size:15px;color:#1a1a1a">Para confirmar sua inscrição, clique no botão abaixo:</p>
<p style="margin:28px 0">
<a href="${url}" style="display:inline-block;background:#0F3D5C;color:#ffffff;text-decoration:none;padding:12px 22px;border-radius:3px;font-size:14px;letter-spacing:0.05em;text-transform:uppercase">Confirmar inscrição</a>
</p>
<p style="line-height:1.6;font-size:13px;color:#5b6166">Se você não solicitou esta inscrição, ignore este e-mail.</p>
<hr style="border:none;border-top:1px solid #e2dfd8;margin:24px 0">
<p style="font-size:12px;color:#8a8e93;line-height:1.6">Observatório do Senado · Iniciativa cidadã independente de transparência parlamentar.<br><a href="${SITE}" style="color:#0F3D5C">observasenado.org</a></p>
</div>
</body></html>`
}
function welcomeHtml(unsubToken: string): string {
const url = `${API_BASE}/api/newsletter/unsubscribe?token=${unsubToken}`
return `
<!doctype html>
<html lang="pt-BR"><body style="font-family:system-ui,Arial,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#1a1a1a;background:#f7f6f3">
<div style="border:1px solid #e2dfd8;background:#ffffff;padding:32px;border-radius:4px">
<h1 style="font-family:Georgia,serif;color:#0F3D5C;margin:0 0 16px;font-size:22px">Inscrição confirmada</h1>
<p style="line-height:1.6;font-size:15px">Bem-vindo(a) à newsletter do <strong>Observatório do Senado</strong>. Você receberá atualizações periódicas sobre o monitoramento parlamentar, mudanças metodológicas e novas funcionalidades da plataforma.</p>
<p style="line-height:1.6;font-size:15px">Acesse o ranking completo em <a href="${SITE}" style="color:#0F3D5C">observasenado.org</a>.</p>
<hr style="border:none;border-top:1px solid #e2dfd8;margin:24px 0">
<p style="font-size:12px;color:#8a8e93;line-height:1.6">Para cancelar sua inscrição a qualquer momento, <a href="${url}" style="color:#0F3D5C">clique aqui</a>.</p>
</div>
</body></html>`
}
function appendUnsubscribe(html: string, unsubToken: string): string {
const url = `${API_BASE}/api/newsletter/unsubscribe?token=${unsubToken}`
const footer = `<hr style="border:none;border-top:1px solid #e2dfd8;margin:32px 0 16px"><p style="font-size:11px;color:#8a8e93;line-height:1.6;text-align:center">Você está recebendo este e-mail porque se inscreveu na newsletter do Observatório do Senado.<br><a href="${url}" style="color:#5b6166">Cancelar inscrição</a> · <a href="${SITE}" style="color:#5b6166">observasenado.org</a></p>`
if (html.includes('</body>')) return html.replace('</body>', `${footer}</body>`)
return html + footer
}
// ============================================================================
// PUBLIC ENDPOINTS
// ============================================================================
newsletter.post('/subscribe', async (c) => {
const body = (await c.req.json<{ email?: string }>().catch(() => ({}))) as { email?: string }
const email = (body.email ?? '').trim().toLowerCase()
if (!isValidEmail(email)) {
return c.json({ error: 'E-mail inválido.' }, 400)
}
const token = generateToken()
const ip = c.req.header('cf-connecting-ip') ?? null
const ua = c.req.header('user-agent') ?? null
// Idempotente: se já está active, finge que mandou (privacy)
const existing = await c.env.SENADO_DB.prepare(
'SELECT status FROM newsletter_subscribers WHERE email = ?',
)
.bind(email)
.first<{ status: string }>()
if (existing?.status === 'active') {
return c.json({ ok: true, message: 'Verifique seu e-mail para confirmar a inscrição.' })
}
await c.env.SENADO_DB.prepare(
`INSERT INTO newsletter_subscribers (email, status, token, ip, user_agent)
VALUES (?, 'pending', ?, ?, ?)
ON CONFLICT(email) DO UPDATE SET token=excluded.token, status='pending', ip=excluded.ip, user_agent=excluded.user_agent`,
)
.bind(email, token, ip, ua)
.run()
if (c.env.RESEND_API_KEY) {
const send = await sendEmailViaResend(
c.env.RESEND_API_KEY,
email,
'Confirme sua inscrição — Observatório do Senado',
confirmHtml(token),
)
if (!send.ok) {
console.error('newsletter confirm send failed:', send.error)
}
}
return c.json({ ok: true, message: 'Verifique seu e-mail para confirmar a inscrição.' })
})
newsletter.get('/confirm', async (c) => {
const token = c.req.query('token') ?? ''
if (!token) return c.html(htmlMessage('Link inválido', 'O link de confirmação está incompleto.'), 400)
const row = await c.env.SENADO_DB.prepare(
'SELECT id, email, status FROM newsletter_subscribers WHERE token = ?',
)
.bind(token)
.first<{ id: number; email: string; status: string }>()
if (!row) return c.html(htmlMessage('Link inválido', 'Este link não é mais válido.'), 404)
if (row.status !== 'active') {
await c.env.SENADO_DB.prepare(
`UPDATE newsletter_subscribers SET status='active', confirmed_at=datetime('now') WHERE id=?`,
)
.bind(row.id)
.run()
if (c.env.RESEND_API_KEY) {
await sendEmailViaResend(
c.env.RESEND_API_KEY,
row.email,
'Inscrição confirmada — Observatório do Senado',
welcomeHtml(token),
)
}
}
return c.html(
htmlMessage(
'Inscrição confirmada',
'Obrigado por se inscrever na newsletter do Observatório do Senado. Você receberá nossas próximas atualizações.',
),
)
})
newsletter.get('/unsubscribe', async (c) => {
const token = c.req.query('token') ?? ''
if (!token) return c.html(htmlMessage('Link inválido', 'O link de cancelamento está incompleto.'), 400)
const row = await c.env.SENADO_DB.prepare(
'SELECT id, status FROM newsletter_subscribers WHERE token = ?',
)
.bind(token)
.first<{ id: number; status: string }>()
if (!row) return c.html(htmlMessage('Link inválido', 'Este link não é mais válido.'), 404)
await c.env.SENADO_DB.prepare(
`UPDATE newsletter_subscribers SET status='unsubscribed', unsubscribed_at=datetime('now') WHERE id=?`,
)
.bind(row.id)
.run()
return c.html(
htmlMessage(
'Inscrição cancelada',
'Sua inscrição foi cancelada. Você não receberá mais e-mails do Observatório do Senado.',
),
)
})
// ============================================================================
// ADMIN ENDPOINTS (requer X-Admin-Secret)
// ============================================================================
function requireAdmin(c: any): Response | null {
const provided = c.req.header('x-admin-secret')
if (!c.env.ADMIN_SECRET || provided !== c.env.ADMIN_SECRET) {
return c.json({ error: 'unauthorized' }, 401)
}
return null
}
newsletter.get('/admin/stats', async (c) => {
const unauth = requireAdmin(c)
if (unauth) return unauth
const r = await c.env.SENADO_DB.prepare(
`SELECT status, COUNT(*) as n FROM newsletter_subscribers GROUP BY status`,
).all<{ status: string; n: number }>()
const stats = { pending: 0, active: 0, unsubscribed: 0, total: 0 }
for (const row of r.results) {
;(stats as any)[row.status] = row.n
stats.total += row.n
}
return c.json(stats)
})
newsletter.post('/admin/send', async (c) => {
const unauth = requireAdmin(c)
if (unauth) return unauth
const body = (await c.req
.json<{ subject?: string; html?: string; test_to?: string }>()
.catch(() => ({}))) as { subject?: string; html?: string; test_to?: string }
if (!body.subject || !body.html) {
return c.json({ error: 'subject and html are required' }, 400)
}
if (!c.env.RESEND_API_KEY) {
return c.json({ error: 'RESEND_API_KEY not configured' }, 500)
}
// Modo teste: envia só pra um e-mail
if (body.test_to) {
if (!isValidEmail(body.test_to)) return c.json({ error: 'invalid test_to' }, 400)
const tmpToken = generateToken()
const send = await sendEmailViaResend(
c.env.RESEND_API_KEY,
body.test_to,
`[TESTE] ${body.subject}`,
appendUnsubscribe(body.html, tmpToken),
)
return c.json({ test: true, ok: send.ok, error: send.error })
}
const subs = await c.env.SENADO_DB.prepare(
`SELECT email, token FROM newsletter_subscribers WHERE status='active'`,
).all<{ email: string; token: string }>()
let sent = 0
let failed = 0
const errors: string[] = []
// Rate limit suave: ~10 emails por segundo (Resend free = 2/s, paid = 10/s)
for (const s of subs.results) {
const r = await sendEmailViaResend(
c.env.RESEND_API_KEY,
s.email,
body.subject,
appendUnsubscribe(body.html, s.token),
)
if (r.ok) sent++
else {
failed++
if (errors.length < 10) errors.push(`${s.email}: ${r.error}`)
}
await new Promise((res) => setTimeout(res, 120))
}
return c.json({ sent, failed, total: subs.results.length, errors })
})
// ============================================================================
// ADMIN ENDPOINTS — newsletter_runs e subscribers
// ============================================================================
newsletter.get('/admin/runs', async (c) => {
const unauth = requireAdmin(c)
if (unauth) return unauth
const rows = await c.env.SENADO_DB.prepare(
`SELECT id, edicao, subject, status, enviados, falhas,
computed_at, enviado_em, prompt_tokens, output_tokens, modelo
FROM newsletter_runs ORDER BY id DESC`,
).all<{
id: number
edicao: string
subject: string
status: string
enviados: number
falhas: number
computed_at: string
enviado_em: string | null
prompt_tokens: number | null
output_tokens: number | null
modelo: string | null
}>()
return c.json({ runs: rows.results })
})
newsletter.get('/admin/run/:edicao', async (c) => {
const unauth = requireAdmin(c)
if (unauth) return unauth
const edicao = c.req.param('edicao')
const row = await c.env.SENADO_DB.prepare(
`SELECT id, edicao, subject, preheader, html, markdown, status,
enviados, falhas, computed_at, enviado_em, modelo
FROM newsletter_runs WHERE edicao = ?`,
)
.bind(edicao)
.first<{
id: number
edicao: string
subject: string
preheader: string | null
html: string
markdown: string
status: string
enviados: number
falhas: number
computed_at: string
enviado_em: string | null
modelo: string | null
}>()
if (!row) return c.json({ error: 'edição não encontrada' }, 404)
return c.json(row)
})
newsletter.get('/admin/subscribers', async (c) => {
const unauth = requireAdmin(c)
if (unauth) return unauth
const rows = await c.env.SENADO_DB.prepare(
`SELECT id, email, status, created_at, confirmed_at, unsubscribed_at
FROM newsletter_subscribers ORDER BY id DESC`,
).all<{
id: number
email: string
status: string
created_at: string
confirmed_at: string | null
unsubscribed_at: string | null
}>()
return c.json({ subscribers: rows.results })
})
newsletter.post('/admin/send-run', async (c) => {
const unauth = requireAdmin(c)
if (unauth) return unauth
const body = (
await c.req
.json<{ edicao?: string; admin_only?: boolean; admin_email?: string }>()
.catch(() => ({}))
) as { edicao?: string; admin_only?: boolean; admin_email?: string }
if (!body.edicao) return c.json({ error: 'edicao obrigatória' }, 400)
if (!c.env.RESEND_API_KEY) return c.json({ error: 'RESEND_API_KEY não configurada' }, 500)
try {
const r = await enviarEdicao(c.env, body.edicao, {
adminOnly: body.admin_only ?? false,
adminEmail: body.admin_email,
})
return c.json(r)
} catch (err) {
return c.json({ error: String(err) }, 400)
}
})
// ============================================================================
// helpers
// ============================================================================
function htmlMessage(title: string, body: string): string {
return `<!doctype html>
<html lang="pt-BR"><head><meta charset="utf-8"><title>${title} — Observatório do Senado</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
body{font-family:system-ui,Arial,sans-serif;background:#f7f6f3;color:#1a1a1a;margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.card{max-width:520px;width:100%;background:#fff;border:1px solid #e2dfd8;border-radius:4px;padding:40px;text-align:center}
h1{font-family:Georgia,serif;color:#0F3D5C;margin:0 0 16px;font-size:22px}
p{line-height:1.6;font-size:15px;color:#1a1a1a;margin:8px 0}
a{color:#0F3D5C;text-decoration:none;font-weight:500}
.foot{margin-top:24px;padding-top:16px;border-top:1px solid #e2dfd8;font-size:12px;color:#8a8e93}
</style></head>
<body><div class="card">
<h1>${title}</h1>
<p>${body}</p>
<p class="foot"><a href="${SITE}">← Voltar para observasenado.org</a></p>
</div></body></html>`
}