From 018d1d92946dd5b8fcf7c4b086dfb4fc75158260 Mon Sep 17 00:00:00 2001 From: Theodore Song Date: Sun, 26 Jul 2026 14:13:34 -0400 Subject: [PATCH] Add SMTP support for trade emails --- .env.example | 8 +++++- api/live.js | 73 ++++++++++++++++++++++++++++++++++++++++++----- index.html | 21 ++++++++++++-- package-lock.json | 10 +++++++ package.json | 1 + 5 files changed, 103 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 6bb0218..cacbd40 100644 --- a/.env.example +++ b/.env.example @@ -85,11 +85,17 @@ CUSTOMER_SUPPORT_EMAIL= RISK_ADMIN_WALLET= # Trade email alerts -# Use either Resend directly or a webhook from Zapier/Make/another email automation. +# Use Resend, SMTP, or a webhook from Zapier/Make/another email automation. RESEND_API_KEY= TRADE_EMAIL_FROM=Poly Arena TRADE_EMAIL_REPLY_TO= TRADE_EMAIL_WEBHOOK_URL= +SMTP_HOST= +SMTP_PORT= +SMTP_SECURE=true +SMTP_USER= +SMTP_PASS= +SMTP_FROM= # Agent chat OPENAI_API_KEY= diff --git a/api/live.js b/api/live.js index 724067f..74ebb43 100644 --- a/api/live.js +++ b/api/live.js @@ -9,6 +9,7 @@ import { updateRealPositionMark, updateTradeTicketStatus, } from "./_db.js"; +import nodemailer from "nodemailer"; const REQUIRED_ENV = [ ["PRODUCTION_APP_URL", "Production app URL"], @@ -173,6 +174,28 @@ function stackStatus() { }); } +function tradeEmailStatus() { + const resend = Boolean(process.env.RESEND_API_KEY); + const webhook = Boolean(process.env.TRADE_EMAIL_WEBHOOK_URL); + const smtpMissing = ["SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASS"].filter((key) => !process.env[key]); + const smtp = smtpMissing.length === 0; + const provider = resend ? "resend" : (smtp ? "smtp" : (webhook ? "webhook" : null)); + return { + configured: Boolean(provider), + provider, + options: { + resend: { configured: resend, missing: resend ? [] : ["RESEND_API_KEY"] }, + smtp: { configured: smtp, missing: smtpMissing }, + webhook: { configured: webhook, missing: webhook ? [] : ["TRADE_EMAIL_WEBHOOK_URL"] }, + }, + missing_any_one_of: provider ? [] : [ + "RESEND_API_KEY", + "or SMTP_HOST + SMTP_PORT + SMTP_USER + SMTP_PASS", + "or TRADE_EMAIL_WEBHOOK_URL", + ], + }; +} + function liveTradingReady() { const requiredConfigured = providerStatus().every((x) => x.configured); const liveFlagEnabled = process.env.LIVE_TRADING_ENABLED === "true"; @@ -214,6 +237,7 @@ function baseStatus() { : "Live trading is locked until eligibility, payments, wallet/deposit-wallet signing, Polymarket CLOB credentials, audit storage, monitoring, and LIVE_TRADING_ENABLED=true are configured.", providers, provider_stack: stackStatus(), + trade_email: tradeEmailStatus(), personal_requirements: personal, launch_requirements: LAUNCH_REQUIREMENTS.map(([key, label]) => ({ key, label })), webhooks: WEBHOOK_ROUTES.map(([provider, label, path]) => ({ @@ -336,6 +360,31 @@ async function sendTradeEmail({ to, events, appUrl, test }) { return { status: 200, body: { ok: true, provider: "resend", id: data?.id || null, sent: events.length } }; } + if (process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS) { + const port = Number(process.env.SMTP_PORT); + const secure = String(process.env.SMTP_SECURE || "").toLowerCase() === "true" || port === 465; + const from = process.env.TRADE_EMAIL_FROM || process.env.SMTP_FROM || process.env.SMTP_USER; + const replyTo = process.env.TRADE_EMAIL_REPLY_TO || process.env.CUSTOMER_SUPPORT_EMAIL || undefined; + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port, + secure, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + const info = await transporter.sendMail({ + from, + to: email, + replyTo, + subject: payload.subject, + text: payload.text, + html: payload.html, + }); + return { status: 200, body: { ok: true, provider: "smtp", id: info?.messageId || null, sent: events.length } }; + } + if (process.env.TRADE_EMAIL_WEBHOOK_URL) { const response = await fetch(process.env.TRADE_EMAIL_WEBHOOK_URL, { method: "POST", @@ -350,7 +399,8 @@ async function sendTradeEmail({ to, events, appUrl, test }) { status: 501, body: { ok: false, - error: "Email alerts need RESEND_API_KEY plus TRADE_EMAIL_FROM, or TRADE_EMAIL_WEBHOOK_URL, in Vercel environment variables.", + error: "Email alerts need a delivery provider in Vercel: RESEND_API_KEY, or SMTP_HOST + SMTP_PORT + SMTP_USER + SMTP_PASS, or TRADE_EMAIL_WEBHOOK_URL.", + trade_email: tradeEmailStatus(), }, }; } @@ -627,12 +677,21 @@ export default async function handler(req, res) { if (body.action === "trade_email") { const events = normalizeTradeEmailEvents(body.events); - const result = await sendTradeEmail({ - to: body.to, - events, - appUrl: body.app_url, - test: Boolean(body.test), - }); + let result; + try { + result = await sendTradeEmail({ + to: body.to, + events, + appUrl: body.app_url, + test: Boolean(body.test), + }); + } catch (err) { + return res.status(502).json({ + ok: false, + error: err?.response || err?.message || "Email provider failed to send the alert.", + trade_email: tradeEmailStatus(), + }); + } if (result.body?.ok) { await recordAuditEvent("TRADE_EMAIL_ALERT_SENT", { to_domain: String(body.to || "").split("@")[1] || null, diff --git a/index.html b/index.html index 05978e3..2766b08 100644 --- a/index.html +++ b/index.html @@ -739,7 +739,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
- Build agent-email-filters · Paper trading only · Live prices from Polymarket's public Gamma API · Not financial advice · + Build email-provider-smtp · Paper trading only · Live prices from Polymarket's public Gamma API · Not financial advice · Source on GitHub
@@ -796,6 +796,7 @@ let LIVE_MARKET_OFFSET = 0; let LIVE_MARKET_QUERY = ""; let LIVE_BACKEND_STATUS = null; let LIVE_POLICY_STATUS = null; +let TRADE_EMAIL_STATUS = null; let PERSONAL_TICKETS = []; let REAL_PORTFOLIO = {positions:[],fills:[]}; let PERSONAL_CAPITAL = {account:{cash:0},allocations:[],events:[]}; @@ -833,6 +834,14 @@ async function initProviderConfig(){ } }catch(e){} } +async function refreshTradeEmailStatus(){ + try{ + const r=await fetch("/api/live",{cache:"no-store"}); + const d=await r.json(); + TRADE_EMAIL_STATUS=d&&d.trade_email?d.trade_email:null; + }catch(e){TRADE_EMAIL_STATUS=null;} + renderEmailAlerts(); +} /* ---------- the ten competing agents ---------- Each shares the same frequently refreshed suggestions but ranks/sizes them differently. */ @@ -1974,9 +1983,15 @@ function renderEmailAlerts(){ `).join(""); + const providerLine=TRADE_EMAIL_STATUS + ? (TRADE_EMAIL_STATUS.configured + ? `Email backend ready via ${TRADE_EMAIL_STATUS.provider}.` + : `Email backend missing: add Resend, SMTP, or webhook env vars in Vercel.`) + : "Checking email backend..."; + const sentLine=cfg.last_sent_at?` Last sent ${new Date(cfg.last_sent_at).toLocaleString()}.`:""; status.textContent=cfg.last_error ? `Last error: ${cfg.last_error}` - : (cfg.last_sent_at?`Last sent ${new Date(cfg.last_sent_at).toLocaleString()}.`:"Send a digest whenever selected agents open, close, stop out, or take gains during a cycle."); + : `${providerLine}${sentLine} Sends a digest whenever selected agents trade during a cycle.`; const readAlertForm=()=>{ const email=input.value.trim(); const events={}; @@ -2011,6 +2026,7 @@ function renderEmailAlerts(){ equity:STARTING_BALANCE, return_pct:0, }],{test:true}); + await refreshTradeEmailStatus(); pushCloudState(true); }; document.querySelectorAll("[data-email-event],[data-email-agent]").forEach(el=>el.onchange=()=>{ @@ -3564,6 +3580,7 @@ window.addEventListener("hashchange",()=>showTab(location.hash.slice(1))); /* ---------- Wire up ---------- */ applyPersonalMode(); initProviderConfig(); +refreshTradeEmailStatus(); $("runBtn").addEventListener("click",async()=>{ const btn=$("runBtn");btn.disabled=true;btn.textContent="Running…"; try{const r=await runDailyCycle();const b=board();toast(r.busy?`Cycle already running · ${b[0].c.emoji} ${b[0].c.name} leads`:r.skipped?`Already ran this minute · ${b[0].c.emoji} ${b[0].c.name} leads`:`Cycle done · ${r.suggestions} ideas · ${b[0].c.emoji} ${b[0].c.name} leads`);renderAll();} diff --git a/package-lock.json b/package-lock.json index 16b2e12..8f925e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "dependencies": { "@neondatabase/serverless": "^1.1.0", "@vercel/blob": "2.5.0", + "nodemailer": "^9.0.3", "svix": "^1.96.1" } }, @@ -222,6 +223,15 @@ "node": ">=6" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", diff --git a/package.json b/package.json index cc89b09..c242869 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "dependencies": { "@neondatabase/serverless": "^1.1.0", "@vercel/blob": "2.5.0", + "nodemailer": "^9.0.3", "svix": "^1.96.1" } }