Postal v1.1: crash resume, A/B subjects, send-hours window, segments, suppression, unsub endpoint, watchdog, Proton SMTP

This commit is contained in:
Hermes
2026-10-02 02:03:18 -07:00
commit b3c3cec909
6 changed files with 927 additions and 0 deletions

361
app.py Normal file
View File

@@ -0,0 +1,361 @@
#!/usr/bin/env python3
"""Mailer GUI — Flask web app, localhost:8899 on the VPS (RDP into it and open the browser)."""
import os, sys, csv, io, json
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from flask import Flask, request, jsonify, render_template_string, redirect, url_for
from engine import (db, init_db, load_config, save_config, get_engine, render_subject)
APP_DIR = os.path.dirname(os.path.abspath(__file__))
app = Flask(__name__)
init_db()
PAGE = r"""<!doctype html><html><head><meta charset=utf-8><title>Mailer</title>
<style>
:root{--bg:#0d0f1a;--panel:#151827;--panel2:#1c2033;--line:#2a2f4a;--txt:#e8eaf6;--dim:#9298b8;
--acc:#7c6cf5;--acc2:#5e8bff;--ok:#4ade80;--bad:#f87171;--warn:#fbbf24}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--txt);font:15px/1.5 'Segoe UI',system-ui,sans-serif;padding:20px}
.wrap{max-width:1180px;margin:0 auto}
h1{font-size:26px;margin-bottom:4px}h1 .dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--ok);margin-left:8px;animation:pulse 2s infinite}
@keyframes pulse{50%{opacity:.3}}
.sub{color:var(--dim);margin-bottom:18px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px;margin-bottom:14px}
.card h2{font-size:15px;color:var(--acc);margin-bottom:10px;text-transform:uppercase;letter-spacing:.6px}
.full{grid-column:1/-1}
label{display:block;font-size:12.5px;color:var(--dim);margin:8px 0 3px}
input[type=text],input[type=number],textarea,select{width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:8px 10px;font-size:14px}
textarea{min-height:110px;resize:vertical;font-family:Consolas,monospace;font-size:13px}
button{background:linear-gradient(135deg,var(--acc),var(--acc2));border:0;border-radius:9px;color:#fff;padding:9px 20px;font-size:14.5px;font-weight:600;cursor:pointer;margin-top:10px}
button:hover{filter:brightness(1.15)}
button.stop{background:linear-gradient(135deg,#e05555,#c22f2f)}
button.small{padding:5px 12px;font-size:12.5px;margin-top:6px}
.row{display:flex;gap:10px}.row>*{flex:1}
table{width:100%;border-collapse:collapse;font-size:13.5px}
th{color:var(--dim);text-align:left;font-weight:600;padding:6px 8px;border-bottom:1px solid var(--line)}
td{padding:6px 8px;border-bottom:1px solid var(--line)}
.stats{display:flex;gap:12px;flex-wrap:wrap;margin:10px 0}
.stat{background:var(--panel2);border-radius:11px;padding:10px 18px;text-align:center;min-width:110px}
.stat .n{font-size:26px;font-weight:700}
.stat .l{font-size:11.5px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px}
.badge{display:inline-block;padding:2px 9px;border-radius:20px;font-size:11.5px;font-weight:600}
.b-sent{background:#14532d;color:var(--ok)} .b-queued{background:#1e3a8a;color:#93c5fd}
.b-failed{background:#7f1d1d;color:var(--bad)} .b-personalizing{background:#78350f;color:var(--warn)}
.tip{cursor:help;border-bottom:1px dotted var(--dim)}
#log{background:#0a0c14;border:1px solid var(--line);border-radius:8px;padding:10px;font:12px Consolas,monospace;max-height:180px;overflow-y:auto;color:#8f9}
.bar{height:8px;background:var(--panel2);border-radius:6px;overflow:hidden;margin:8px 0}
.bar>div{height:100%;background:linear-gradient(90deg,var(--acc),var(--acc2));transition:width .6s}
.tun{display:flex;align-items:center;gap:8px;margin:6px 0}
.tun input[type=number]{width:90px}
</style></head><body><div class=wrap>
<h1>Postal<span class=dot id=livedot></span></h1>
<div class=sub>Ollama-personalized campaign mailer — subscribers only, SES delivery</div>
<div class=stats id=stats></div>
<div class=bar><div id=prog style="width:0%"></div></div>
<div class=grid>
<div class="card full">
<h2>Engine</h2>
<div id=engineline>Loading…</div>
<button onclick=start() id=btnstart>Start campaign</button>
<button class=stop onclick=stopEngine()>STOP</button>
<span id=stopnote style="color:var(--warn);margin-left:10px;display:none">Stop requested — finishing current email…</span>
</div>
<div class=card>
<h2><span class=tip title="Plain-text or CSV: one email per line, or email,firstname,lastname,interest">Subscribers</span></h2>
<textarea id=subs placeholder="email,firstname,interest&#10;jane@example.com,Jane,bitcoin mining&#10;bob@example.com,Bob,"></textarea>
<button class=small onclick=importSubs()>Import</button> <span id=impnote></span>
<div style=margin-top:8px id=subcount></div>
</div>
<div class=card>
<h2>Campaign</h2>
<label>Subject</label>
<input type=text id=subject placeholder="Hey {{first_name}} — quick one">
<label>Segment: only subscribers whose interest contains… (blank = everyone)</label>
<input type=text id=segment placeholder="bitcoin">
<label>Body (HTML). Tags: {{first_name}} {{interest}} {{from_name}} — and <b>{{intro}}</b> = the AI-written personal bit</label>
<textarea id=body placeholder="<p>{{intro}}</p>&#10;<p>Check out what we shipped: …</p>"></textarea>
<button class=small onclick=saveCampaign()>Save campaign</button> <span id=campnote></span>
<div class=row style=margin-top:8px>
<input type=text id=testemail placeholder="your@email — test send to">
<button class=small onclick=testSend()>Test send</button>
</div><div id=testnote style="color:var(--warn);font-size:12.5px"></div>
</div>
<div class=card>
<h2><span class=tip title="Settings for the sending engine. Hover anything underlined.">Tunables</span></h2>
<div class=tun><label style=margin:0>Ollama URL</label><input type=text id=c_ollama_url>
<span class=tip title="Where the LLM brain lives. Tailscale IP of nightmare (100.x). The app checks it live.">?</span></div>
<div class=tun><label style=margin:0>Model</label><input type=text id=c_ollama_model>
<span class=tip title="Ollama model name, e.g. gemma4:latest. Run 'ollama list' on nightmare to see options.">?</span></div>
<div class=tun><label style=margin:0>Rate/min</label><input type=number id=c_rate_per_minute>
<span class=tip title="Emails per minute. 60/min = 3600/hr. Keep at or under 60 while warming your sender reputation.">?</span></div>
<div class=tun><label style=margin:0>Batch size</label><input type=number id=c_batch_size>
<span class=tip title="Emails between cooldown pauses.">?</span></div>
<div class=tun><label style=margin:0>Pause secs</label><input type=number id=c_pause_between_batches_sec>
<span class=tip title="Cooldown between batches — lets SES API breathe and keeps deliverability high.">?</span></div>
<div class=tun><label><input type=checkbox id=c_personalize> Personalize with Ollama</label>
<span class=tip title="Off = sends the template as-is. On = writes a custom intro per person.">?</span></div>
<div class=tun><label style=margin:0>From name</label><input type=text id=c_from_name></div>
<div class=tun><label style=margin:0>From email (SES-verified)</label><input type=text id=c_from_email>
<span class=tip title="Must be a domain/email verified in Amazon SES first.">?</span></div>
<div class=tun><label style=margin:0>Reply-to</label><input type=text id=c_reply_to></div>
<div class=tun><label style=margin:0>AWS region</label><input type=text id=c_ses_region></div>
<div class=tun><label style=margin:0>Send mode</label><select id=c_send_mode><option value=smtp>SMTP (works now)</option><option value=ses>SES API (needs AWS keys)</option></select>
<span class=tip title="SMTP = your iCloud account, fine for tests/small sends (iCloud caps ~200/day). SES = Amazon, built for 100K+.">?</span></div>
<div class=tun><label style=margin:0>SMTP host</label><input type=text id=c_smtp_host></div>
<div class=tun><label style=margin:0>SMTP port</label><input type=number id=c_smtp_port></div>
<div class=tun><label style=margin:0>SMTP user</label><input type=text id=c_smtp_user></div>
<div class=tun><label style=margin:0>SMTP app password</label><input type=text id=c_smtp_pass></div>
<label>Personalization prompt</label>
<textarea id=c_personalize_prompt style=min-height:70px></textarea>
<button class=small onclick=saveCfg()>Save settings</button> <span id=cfgnote></span>
</div>
<div class=card>
<h2><span class=tip title="Emails that must NEVER be mailed: unsubscribes, complainers, past customers who opted out.">Suppression list</span></h2>
<textarea id=supp placeholder="one email per line"></textarea>
<button class=small onclick=doSuppress()>Add to suppression</button> <span id=suppnote></span>
</div>
<div class=card>
<h2>Subject A/B (current campaign)</h2>
<div id=abdiv style="font-size:13px;color:var(--dim)">no data yet</div>
</div>
<div class="card full">
<h2>Recent sends</h2>
<table id=recent><tr><th>email</th><th>status</th><th>subject</th><th>time</th></tr></table>
</div>
</div>
</div>
<script>
async function j(url, body){const o = body?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:{}; const r = await fetch(url,o); return r.json();}
async function refresh(){
const st = await j('/api/status');
const c = st.counts||{};
const sent=c.sent||0, queued=c.queued||0, failed=c.failed||0, pers=c.personalizing||0;
const total = st.total|| (sent+queued+failed+pers);
let h = `<div class=stat><div class=n>${total||'-'}</div><div class=l>queue</div></div>
<div class=stat><div class=n style=color:var(--ok)>${sent}</div><div class=l>sent</div></div>
<div class=stat><div class=n style=color:var(--warn)>${pers}</div><div class=l>writing…</div></div>
<div class=stat><div class=n>${queued}</div><div class=l>waiting</div></div>
<div class=stat><div class=n style=color:var(--bad)>${failed}</div><div class=l>failed</div></div>`;
document.getElementById('stats').innerHTML=h;
const pct = total? Math.round(100*(sent+failed)/total):0;
document.getElementById('prog').style.width=pct+'%';
document.getElementById('livedot').style.background = st.running?'var(--ok)':'var(--line)';
document.getElementById('engineline').innerHTML = st.running?
`Running — <b>${st.current}</b> (${pct}%)` : `Idle. ${st.stopNote?'<span style=color:var(--warn)>Stop pending.</span>':''}`;
const r2 = await j('/api/recent');
document.getElementById('recent').innerHTML = '<tr><th>email</th><th>status</th><th>subject</th><th>time</th></tr>' +
r2.map(s=>`<tr><td>${s.email}</td><td><span class="badge b-${s.status}">${s.status}</span></td><td>${(s.subject||'').slice(0,50)}</td><td>${s.sent_at||''}</td></tr>`).join('');
}
async function pullCfg(){
const c = await j('/api/config');
for (const k in c){ const el=document.getElementById('c_'+k); if(!el) continue;
if(el.type==='checkbox') el.checked=!!c[k]; else el.value=c[k]; }
}
async function saveCfg(){
const keys=['ollama_url','ollama_model','rate_per_minute','batch_size','pause_between_batches_sec','personalize','from_name','from_email','reply_to','ses_region','personalize_prompt','send_mode','smtp_host','smtp_port','smtp_user','smtp_pass','send_hours','ab_subjects'];
const out={};
keys.forEach(k=>{const el=document.getElementById('c_'+k); if(!el)return;
out[k]= el.type==='checkbox'? el.checked : (el.type==='number'? +el.value : el.value);});
await j('/api/config', out);
document.getElementById('cfgnote').textContent='saved ✓'; setTimeout(()=>document.getElementById('cfgnote').textContent='',2000);
}
async function importSubs(){
const text=document.getElementById('subs').value;
const r=await j('/api/import',{text});
document.getElementById('impnote').textContent=`${r.added} added, ${r.dupes} dupes`;
refreshCounts();
}
async function refreshCounts(){
const r=await j('/api/subcount'); document.getElementById('subcount').textContent=`${r.active} active subscribers in DB`;
}
async function saveCampaign(){
const r=await j('/api/campaign',{subject:document.getElementById('subject').value,body:document.getElementById('body').value,segment:document.getElementById('segment').value});
document.getElementById('campnote').textContent='saved ✓ (#'+r.id+')';
}
async function doSuppress(){
const text=document.getElementById('supp').value;
const r=await j('/api/suppress',{text});
document.getElementById('suppnote').textContent=`${r.added} added (${r.total} total suppressed)`;
document.getElementById('supp').value='';
}
async function refreshAB(){
const r=await j('/api/abstats');
const el=document.getElementById('abdiv');
if(!r.length){ el.textContent='no data yet'; return; }
el.innerHTML = r.map(v=>`<div>· ${v.subject} <b style=color:var(--txt)>${v.sent}</b> sent</div>`).join('');
}
async function testSend(){
const email=document.getElementById('testemail').value;
const note=document.getElementById('testnote');
note.textContent='sending (personalizing with gemma4 takes ~20s)...';
const r=await j('/api/testsend',{email});
note.textContent = r.ok? ('sent ✓ id='+r.id+' intro: '+r.intro) : ('FAILED: '+r.error);
if(r.ok) setTimeout(refresh, 1500);
}
async function start(){ const r=await j('/api/start',{}); if(!r.ok) alert(r.error); }
async function stopEngine(){ await j('/api/stop',{}); document.getElementById('stopnote').style.display='inline'; }
setInterval(refresh,3000); refresh(); pullCfg(); refreshCounts(); refreshAB(); setInterval(refreshAB,15000);
</script></body></html>"""
@app.route("/")
def home():
return render_template_string(PAGE)
@app.route("/api/status")
def api_status():
eng = get_engine()
st = eng.status()
st["stopNote"] = load_config().get("stopped", False)
return jsonify(st)
@app.route("/api/recent")
def api_recent():
c = db()
rows = c.execute("SELECT email,status,subject,sent_at FROM sends ORDER BY id DESC LIMIT 12").fetchall()
c.close()
return jsonify([dict(r) for r in rows])
@app.route("/api/config", methods=["GET", "POST"])
def api_config():
if request.method == "POST":
cfg = load_config()
incoming = request.json or {}
for k, v in incoming.items():
if k in cfg:
cfg[k] = v
# engine stop flag
cfg["stopped"] = bool(incoming.get("_stop", cfg.get("stopped")))
save_config(cfg)
return jsonify(ok=True)
return jsonify(load_config())
@app.route("/api/import", methods=["POST"])
def api_import():
text = (request.json or {}).get("text", "")
c = db()
added = dupes = 0
for raw in io.StringIO(text).readlines():
raw = raw.strip()
if not raw or raw.startswith("#"): continue
parts = [p.strip() for p in raw.replace(";", ",").split(",")]
email = parts[0].lower()
if "@" not in email: continue
fn = parts[1] if len(parts) > 1 else ""
ln = parts[2] if len(parts) > 2 else ""
interest = parts[3] if len(parts) > 3 else (parts[1] if len(parts) > 1 and "@" not in parts[1] and len(parts) <= 2 else "")
try:
c.execute("INSERT INTO subscribers (email, first_name, last_name, interest) VALUES (?,?,?,?)",
(email, fn, ln, interest))
added += 1
except Exception:
dupes += 1
c.commit(); c.close()
return jsonify(added=added, dupes=dupes)
@app.route("/api/subcount")
def api_subcount():
c = db()
n = c.execute("SELECT COUNT(*) n FROM subscribers WHERE status='active'").fetchone()["n"]
c.close()
return jsonify(active=n)
@app.route("/api/campaign", methods=["POST"])
def api_campaign():
d = request.json or {}
c = db()
cur = c.execute("INSERT INTO campaigns (name, subject, body, status, segment_interest) VALUES (?,?,?, 'ready', ?)",
(d.get("subject", "Campaign")[:40], d.get("subject", ""), d.get("body", ""),
(d.get("segment", "") or "").strip()))
cid = cur.lastrowid
c.commit(); c.close()
return jsonify(id=cid)
@app.route("/api/start", methods=["POST"])
def api_start():
cfg = load_config()
cfg["stopped"] = False
save_config(cfg)
c = db()
row = c.execute("SELECT id FROM campaigns ORDER BY id DESC LIMIT 1").fetchone()
c.close()
if not row:
return jsonify(ok=False, error="save a campaign first")
return jsonify(get_engine().run_campaign(row["id"]))
@app.route("/api/suppress", methods=["POST"])
def api_suppress():
text = (request.json or {}).get("text", "")
c = db()
n = 0
for raw in io.StringIO(text).readlines():
e = raw.strip().lower()
if "@" in e:
c.execute("INSERT OR IGNORE INTO suppression (email, reason) VALUES (?, 'import')", (e,))
n += 1
c.commit()
total = c.execute("SELECT COUNT(*) n FROM suppression").fetchone()["n"]
c.close()
return jsonify(added=n, total=total)
@app.route("/api/abstats")
def api_abstats():
c = db()
rows = c.execute("SELECT subject, sent, opened FROM ab_variants WHERE campaign_id=(SELECT MAX(id) FROM campaigns)").fetchall()
c.close()
return jsonify([dict(r) for r in rows])
@app.route("/unsub/<email>")
def unsub(email):
e = email.strip().lower()
if "@" not in e:
return "bad link", 400
c = db()
c.execute("INSERT OR IGNORE INTO suppression (email, reason) VALUES (?, 'unsubscribe')", (e,))
c.execute("UPDATE subscribers SET status='unsubscribed' WHERE email=?", (e,))
c.commit()
c.close()
return render_template_string("<body style='background:#0d0f1a;color:#e8eaf6;font-family:sans-serif;display:grid;place-items:center;height:100vh'><div style='text-align:center'><h1>✓</h1><p>You're unsubscribed. No hard feelings.</p></div></body>")
@app.route("/api/testsend", methods=["POST"])
def api_testsend():
d = request.json or {}
to_email = (d.get("email") or "").strip().lower()
if "@" not in to_email:
return jsonify(ok=False, error="bad email")
from engine import OllamaClient, render_template, render_subject, personalize_intro
cfg = load_config()
sub = {"first_name": d.get("first_name", "Jane"), "last_name": "",
"email": to_email, "interest": d.get("interest", "our updates")}
intro = ""
if cfg.get("personalize"):
oc = OllamaClient(cfg["ollama_url"], cfg["ollama_model"])
intro = personalize_intro(oc, sub, cfg)
c = db()
camp = c.execute("SELECT * FROM campaigns ORDER BY id DESC LIMIT 1").fetchone()
c.close()
subject = render_subject(camp["subject"] if camp else "Hello {{first_name}}", sub)
body = render_template(camp["body"] if camp else "<p>{{intro}}</p>", sub, cfg, intro)
try:
from engine import Sender
mid = Sender(cfg).send(to_email, subject, body)
return jsonify(ok=True, id=mid, intro=intro[:200])
except Exception as e:
return jsonify(ok=False, error=str(e)[:300])
@app.route("/api/stop", methods=["POST"])
def api_stop():
eng = get_engine()
eng.state["running"] = False
cfg = load_config(); cfg["stopped"] = True; save_config(cfg)
return jsonify(ok=True)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8899, threaded=True)