Add SMTP support for trade emails

This commit is contained in:
Theodore Song
2026-07-26 14:13:34 -04:00
parent 86fa65f8f1
commit 018d1d9294
5 changed files with 103 additions and 10 deletions
+7 -1
View File
@@ -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 <alerts@yourdomain.com>
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=
+66 -7
View File
@@ -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,
+19 -2
View File
@@ -739,7 +739,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
</section>
<footer>
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 ·
<a class="market-link" href="https://github.com/theodore-song/polymarket-analyst" target="_blank" rel="noopener">Source on GitHub</a>
</footer>
</div>
@@ -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(){
<label class="chip ${cfg.agents&&cfg.agents[a.id]?"active":""}" style="cursor:pointer;border-color:${cfg.agents&&cfg.agents[a.id]?a.color:""}">
<input type="checkbox" data-email-agent="${esc(a.id)}" ${cfg.agents&&cfg.agents[a.id]?"checked":""} style="display:none"> ${a.emoji} ${esc(a.name)}
</label>`).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();}
+10
View File
@@ -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",
+1
View File
@@ -2,6 +2,7 @@
"dependencies": {
"@neondatabase/serverless": "^1.1.0",
"@vercel/blob": "2.5.0",
"nodemailer": "^9.0.3",
"svix": "^1.96.1"
}
}