mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-07-27 16:07:46 +00:00
Add SMTP support for trade emails
This commit is contained in:
+7
-1
@@ -85,11 +85,17 @@ CUSTOMER_SUPPORT_EMAIL=
|
|||||||
RISK_ADMIN_WALLET=
|
RISK_ADMIN_WALLET=
|
||||||
|
|
||||||
# Trade email alerts
|
# 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=
|
RESEND_API_KEY=
|
||||||
TRADE_EMAIL_FROM=Poly Arena <alerts@yourdomain.com>
|
TRADE_EMAIL_FROM=Poly Arena <alerts@yourdomain.com>
|
||||||
TRADE_EMAIL_REPLY_TO=
|
TRADE_EMAIL_REPLY_TO=
|
||||||
TRADE_EMAIL_WEBHOOK_URL=
|
TRADE_EMAIL_WEBHOOK_URL=
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=
|
||||||
|
SMTP_SECURE=true
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM=
|
||||||
|
|
||||||
# Agent chat
|
# Agent chat
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|||||||
+66
-7
@@ -9,6 +9,7 @@ import {
|
|||||||
updateRealPositionMark,
|
updateRealPositionMark,
|
||||||
updateTradeTicketStatus,
|
updateTradeTicketStatus,
|
||||||
} from "./_db.js";
|
} from "./_db.js";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
const REQUIRED_ENV = [
|
const REQUIRED_ENV = [
|
||||||
["PRODUCTION_APP_URL", "Production app URL"],
|
["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() {
|
function liveTradingReady() {
|
||||||
const requiredConfigured = providerStatus().every((x) => x.configured);
|
const requiredConfigured = providerStatus().every((x) => x.configured);
|
||||||
const liveFlagEnabled = process.env.LIVE_TRADING_ENABLED === "true";
|
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.",
|
: "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,
|
providers,
|
||||||
provider_stack: stackStatus(),
|
provider_stack: stackStatus(),
|
||||||
|
trade_email: tradeEmailStatus(),
|
||||||
personal_requirements: personal,
|
personal_requirements: personal,
|
||||||
launch_requirements: LAUNCH_REQUIREMENTS.map(([key, label]) => ({ key, label })),
|
launch_requirements: LAUNCH_REQUIREMENTS.map(([key, label]) => ({ key, label })),
|
||||||
webhooks: WEBHOOK_ROUTES.map(([provider, label, path]) => ({
|
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 } };
|
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) {
|
if (process.env.TRADE_EMAIL_WEBHOOK_URL) {
|
||||||
const response = await fetch(process.env.TRADE_EMAIL_WEBHOOK_URL, {
|
const response = await fetch(process.env.TRADE_EMAIL_WEBHOOK_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -350,7 +399,8 @@ async function sendTradeEmail({ to, events, appUrl, test }) {
|
|||||||
status: 501,
|
status: 501,
|
||||||
body: {
|
body: {
|
||||||
ok: false,
|
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") {
|
if (body.action === "trade_email") {
|
||||||
const events = normalizeTradeEmailEvents(body.events);
|
const events = normalizeTradeEmailEvents(body.events);
|
||||||
const result = await sendTradeEmail({
|
let result;
|
||||||
to: body.to,
|
try {
|
||||||
events,
|
result = await sendTradeEmail({
|
||||||
appUrl: body.app_url,
|
to: body.to,
|
||||||
test: Boolean(body.test),
|
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) {
|
if (result.body?.ok) {
|
||||||
await recordAuditEvent("TRADE_EMAIL_ALERT_SENT", {
|
await recordAuditEvent("TRADE_EMAIL_ALERT_SENT", {
|
||||||
to_domain: String(body.to || "").split("@")[1] || null,
|
to_domain: String(body.to || "").split("@")[1] || null,
|
||||||
|
|||||||
+19
-2
@@ -739,7 +739,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>
|
<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>
|
<a class="market-link" href="https://github.com/theodore-song/polymarket-analyst" target="_blank" rel="noopener">Source on GitHub</a>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -796,6 +796,7 @@ let LIVE_MARKET_OFFSET = 0;
|
|||||||
let LIVE_MARKET_QUERY = "";
|
let LIVE_MARKET_QUERY = "";
|
||||||
let LIVE_BACKEND_STATUS = null;
|
let LIVE_BACKEND_STATUS = null;
|
||||||
let LIVE_POLICY_STATUS = null;
|
let LIVE_POLICY_STATUS = null;
|
||||||
|
let TRADE_EMAIL_STATUS = null;
|
||||||
let PERSONAL_TICKETS = [];
|
let PERSONAL_TICKETS = [];
|
||||||
let REAL_PORTFOLIO = {positions:[],fills:[]};
|
let REAL_PORTFOLIO = {positions:[],fills:[]};
|
||||||
let PERSONAL_CAPITAL = {account:{cash:0},allocations:[],events:[]};
|
let PERSONAL_CAPITAL = {account:{cash:0},allocations:[],events:[]};
|
||||||
@@ -833,6 +834,14 @@ async function initProviderConfig(){
|
|||||||
}
|
}
|
||||||
}catch(e){}
|
}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 ----------
|
/* ---------- the ten competing agents ----------
|
||||||
Each shares the same frequently refreshed suggestions but ranks/sizes them differently. */
|
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:""}">
|
<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)}
|
<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("");
|
</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
|
status.textContent=cfg.last_error
|
||||||
? `Last error: ${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 readAlertForm=()=>{
|
||||||
const email=input.value.trim();
|
const email=input.value.trim();
|
||||||
const events={};
|
const events={};
|
||||||
@@ -2011,6 +2026,7 @@ function renderEmailAlerts(){
|
|||||||
equity:STARTING_BALANCE,
|
equity:STARTING_BALANCE,
|
||||||
return_pct:0,
|
return_pct:0,
|
||||||
}],{test:true});
|
}],{test:true});
|
||||||
|
await refreshTradeEmailStatus();
|
||||||
pushCloudState(true);
|
pushCloudState(true);
|
||||||
};
|
};
|
||||||
document.querySelectorAll("[data-email-event],[data-email-agent]").forEach(el=>el.onchange=()=>{
|
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 ---------- */
|
/* ---------- Wire up ---------- */
|
||||||
applyPersonalMode();
|
applyPersonalMode();
|
||||||
initProviderConfig();
|
initProviderConfig();
|
||||||
|
refreshTradeEmailStatus();
|
||||||
$("runBtn").addEventListener("click",async()=>{
|
$("runBtn").addEventListener("click",async()=>{
|
||||||
const btn=$("runBtn");btn.disabled=true;btn.textContent="Running…";
|
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();}
|
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();}
|
||||||
|
|||||||
Generated
+10
@@ -7,6 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neondatabase/serverless": "^1.1.0",
|
"@neondatabase/serverless": "^1.1.0",
|
||||||
"@vercel/blob": "2.5.0",
|
"@vercel/blob": "2.5.0",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"svix": "^1.96.1"
|
"svix": "^1.96.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -222,6 +223,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/npm-run-path": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neondatabase/serverless": "^1.1.0",
|
"@neondatabase/serverless": "^1.1.0",
|
||||||
"@vercel/blob": "2.5.0",
|
"@vercel/blob": "2.5.0",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"svix": "^1.96.1"
|
"svix": "^1.96.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user