/** * Montador de Propostas UserVoz * * Instalação: * npm install nodemailer pdfkit * * Execução: * SMTP_PASSWORD="NOVA_SENHA" node montador-uservoz.mjs */ import http from "node:http"; import crypto from "node:crypto"; import nodemailer from "nodemailer"; import PDFDocument from "pdfkit"; const PORT = Number(process.env.PORT || 3000); const SMTP = { host: process.env.SMTP_HOST || "smtp.hostinger.com", port: Number(process.env.SMTP_PORT || 465), user: process.env.SMTP_USER || "financeiro@uservoz.com", password: process.env.SMTP_PASSWORD, fromName: process.env.SMTP_FROM_NAME || "UserVoz Telecomunicações", cc: process.env.SMTP_CC || "comercial@uservoz.net", }; const enviosRecentes = new Map(); const transportador = SMTP.password ? nodemailer.createTransport({ host: SMTP.host, port: SMTP.port, // Porta 465 usa SSL/TLS direto. secure: SMTP.port === 465, // Porta 587 exige STARTTLS. requireTLS: SMTP.port === 587, auth: { user: SMTP.user, pass: SMTP.password, }, connectionTimeout: 15000, greetingTimeout: 10000, socketTimeout: 30000, // Impede leitura de anexos externos. disableFileAccess: true, disableUrlAccess: true, // Não registrar conteúdo SMTP. logger: false, debug: false, }) : null; function emailValido(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/i.test( String(email || "").trim() ); } function dinheiro(valor) { return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL", }).format(Number(valor) || 0); } function escaparHTML(valor = "") { return String(valor).replace(/[&<>"']/g, (caractere) => { const mapa = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }; return mapa[caractere]; }); } function gerarPDF(proposta) { return new Promise((resolve, reject) => { const documento = new PDFDocument({ size: "A4", margin: 48, info: { Title: `Proposta Comercial ${proposta.numero}`, Author: "UserVoz Telecomunicações", }, }); const partes = []; documento.on("data", (parte) => partes.push(parte)); documento.on("end", () => resolve(Buffer.concat(partes))); documento.on("error", reject); // Cabeçalho documento.rect(0, 0, 595, 105).fill("#0d2f5d"); documento .fillColor("#ffffff") .font("Helvetica-Bold") .fontSize(25) .text("UserVoz", 48, 33); documento .fillColor("#55c7e8") .font("Helvetica") .fontSize(9) .text("TELECOMUNICAÇÕES", 49, 65); documento .fillColor("#ffffff") .font("Helvetica-Bold") .fontSize(16) .text("PROPOSTA COMERCIAL", 350, 42, { width: 195, align: "right", }); // Identificação da proposta documento .fillColor("#17243a") .font("Helvetica-Bold") .fontSize(15) .text(`Proposta ${proposta.numero}`, 48, 130); documento .font("Helvetica") .fontSize(9) .fillColor("#66758b") .text( `Data de emissão: ${new Date().toLocaleDateString("pt-BR")}` ) .text("Validade da proposta: 10 dias"); // Dados do cliente documento .moveDown(1.5) .font("Helvetica-Bold") .fontSize(12) .fillColor("#0d2f5d") .text("DADOS DO CLIENTE"); documento .moveDown(0.4) .font("Helvetica") .fontSize(10) .fillColor("#17243a") .text(`Empresa: ${proposta.empresa || "Não informado"}`) .text(`CNPJ: ${proposta.cnpj || "Não informado"}`) .text(`Contato: ${proposta.contato || "Não informado"}`) .text(`E-mail: ${proposta.email}`) .text(`Telefone: ${proposta.telefone || "Não informado"}`) .text(`Endereço: ${proposta.endereco || "Não informado"}`); // Produtos documento .moveDown(1.4) .font("Helvetica-Bold") .fontSize(12) .fillColor("#0d2f5d") .text("PRODUTOS E SERVIÇOS"); documento.moveDown(0.5); const posicaoX = 48; const larguras = [265, 55, 90, 90]; const cabecalhos = [ "Serviço", "Qtd.", "Valor unitário", "Total", ]; let posicaoY = documento.y; documento .rect(posicaoX, posicaoY, 500, 25) .fill("#eaf3ff"); cabecalhos.forEach((cabecalho, indice) => { const x = posicaoX + larguras .slice(0, indice) .reduce((total, largura) => total + largura, 0); documento .fillColor("#0d2f5d") .font("Helvetica-Bold") .fontSize(8) .text(cabecalho, x + 5, posicaoY + 8, { width: larguras[indice] - 10, }); }); posicaoY += 25; proposta.itens.forEach((item) => { if (posicaoY > 700) { documento.addPage(); posicaoY = 55; } const totalItem = Number(item.quantidade) * Number(item.valor); const valores = [ item.nome, item.quantidade, dinheiro(item.valor), dinheiro(totalItem), ]; documento .rect(posicaoX, posicaoY, 500, 31) .strokeColor("#dfe6ef") .stroke(); valores.forEach((valor, indice) => { const x = posicaoX + larguras .slice(0, indice) .reduce((total, largura) => total + largura, 0); documento .fillColor("#17243a") .font("Helvetica") .fontSize(8) .text(String(valor), x + 5, posicaoY + 9, { width: larguras[indice] - 10, }); }); posicaoY += 31; }); documento.y = posicaoY + 16; documento .font("Helvetica") .fontSize(10) .fillColor("#66758b") .text(`Condição de pagamento: ${proposta.condicao}`); if (Number(proposta.desconto) > 0) { documento.text( `Desconto aplicado: ${proposta.desconto}%` ); } documento .moveDown(0.5) .font("Helvetica-Bold") .fontSize(17) .fillColor("#0d2f5d") .text(`Investimento total: ${dinheiro(proposta.total)}`, { align: "right", }); if (proposta.observacoes) { documento .moveDown(1.2) .font("Helvetica-Bold") .fontSize(11) .fillColor("#0d2f5d") .text("OBSERVAÇÕES"); documento .moveDown(0.3) .font("Helvetica") .fontSize(9) .fillColor("#17243a") .text(proposta.observacoes); } documento .moveDown(2) .font("Helvetica") .fontSize(8) .fillColor("#66758b") .text( "UserVoz Telecomunicações • WhatsApp (31) 3500-3607 • Telefone (31) 3500-3608", { align: "center" } ); documento.end(); }); } function criarTemplateEmail(proposta) { const nome = proposta.contato || proposta.empresa || "cliente"; return `
UserVoz
TELECOMUNICAÇÕES

Sua proposta comercial está pronta

Olá, ${escaparHTML(nome)}.

Preparamos sua proposta comercial da UserVoz Telecomunicações conforme a solução selecionada.

Proposta ${escaparHTML(proposta.numero)}
Investimento: ${escaparHTML(dinheiro(proposta.total))}

O documento completo está anexado em PDF a este e-mail.

Atenciosamente,
Equipe UserVoz Telecomunicações

WhatsApp: (31) 3500-3607
Telefone: (31) 3500-3608
`; } async function lerJSON(requisicao) { const partes = []; let tamanho = 0; for await (const parte of requisicao) { tamanho += parte.length; if (tamanho > 1_000_000) { throw new Error("Solicitação muito grande"); } partes.push(parte); } return JSON.parse( Buffer.concat(partes).toString("utf8") ); } function responderJSON(resposta, status, conteudo) { resposta.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", }); resposta.end(JSON.stringify(conteudo)); } async function enviarProposta(requisicao, resposta) { const dataEnvio = new Date().toISOString(); let destinatario = "não identificado"; try { if (!transportador) { return responderJSON(resposta, 503, { ok: false, mensagem: "A variável SMTP_PASSWORD ainda não foi configurada.", }); } const proposta = await lerJSON(requisicao); destinatario = String(proposta.email || "") .trim() .toLowerCase(); if (!emailValido(destinatario)) { return responderJSON(resposta, 400, { ok: false, mensagem: "Informe um e-mail válido.", }); } if ( !Array.isArray(proposta.itens) || proposta.itens.length === 0 ) { return responderJSON(resposta, 400, { ok: false, mensagem: "Adicione pelo menos um produto.", }); } // Prevenção de envio duplicado const identificador = crypto .createHash("sha256") .update( `${destinatario}|${proposta.numero}|${proposta.total}` ) .digest("hex"); const envioAnterior = enviosRecentes.get(identificador); if ( envioAnterior && Date.now() - envioAnterior < 60_000 ) { return responderJSON(resposta, 409, { ok: false, mensagem: "Esta proposta já foi enviada. Aguarde um minuto.", }); } enviosRecentes.set(identificador, Date.now()); const pdf = await gerarPDF(proposta); const numeroSeguro = String(proposta.numero) .replace(/[^a-z0-9-]/gi, ""); await transportador.sendMail({ from: { name: SMTP.fromName, address: SMTP.user, }, to: destinatario, cc: SMTP.cc, subject: "Sua proposta comercial — UserVoz Telecomunicações", html: criarTemplateEmail(proposta), attachments: [ { filename: `Proposta-UserVoz-${numeroSeguro}.pdf`, content: pdf, contentType: "application/pdf", }, ], }); // Não registra senha, PDF ou conteúdo do e-mail. console.log( JSON.stringify({ data: dataEnvio, destinatario, resultado: "sucesso", }) ); return responderJSON(resposta, 200, { ok: true, mensagem: `Proposta enviada com sucesso para ${destinatario}.`, }); } catch (erro) { console.error( JSON.stringify({ data: dataEnvio, destinatario, resultado: "erro", tipo: erro?.name || "Erro", }) ); return responderJSON(resposta, 500, { ok: false, mensagem: "Não foi possível enviar. Confira o SMTP e tente novamente.", }); } } const HTML = String.raw` Montador de Propostas UserVoz
UserVoz TELECOMUNICAÇÕES
Montador de Propostas

Monte a proposta comercial

Preencha o cliente, escolha os serviços e envie o PDF por e-mail.

1. Dados do cliente

2. Produtos e serviços

3. Itens selecionados

Serviço Qtd. Valor Total
`; const servidor = http.createServer( async (requisicao, resposta) => { const url = new URL( requisicao.url, `http://${requisicao.headers.host || "localhost"}` ); if ( requisicao.method === "GET" && url.pathname === "/" ) { resposta.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "SAMEORIGIN", }); return resposta.end(HTML); } if ( requisicao.method === "POST" && url.pathname === "/api/enviar-proposta" ) { return enviarProposta( requisicao, resposta ); } responderJSON(resposta, 404, { ok: false, mensagem: "Rota não encontrada.", }); } ); servidor.listen(PORT, () => { console.log( `Montador disponível em http://localhost:${PORT}` ); if (!SMTP.password) { console.warn( "SMTP_PASSWORD não configurada. O envio está desativado." ); } });