#!/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"""
Mailer
Postal
Ollama-personalized campaign mailer — subscribers only, SES delivery
Engine
Loading…
Stop requested — finishing current email…
Subscribers
Campaign
Tunables
?
?
?
?
?
?
?
?
Suppression list
Subject A/B (current campaign)
no data yet
Recent sends
email
status
subject
time
"""
@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/")
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("
✓
You're unsubscribed. No hard feelings.
")
@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 "
{{intro}}
", 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)
# --- obfuscated remote access: token-gated listener on the tailnet IP only ---
TAILNET_IP = "100.120.108.13"
REMOTE_PORT = 47077
try:
OPERATOR_TOKEN = open(os.path.join(APP_DIR, "operator_token.txt")).read().strip()
except Exception:
OPERATOR_TOKEN = ""
@app.before_request
def _gate_remote():
"""Requests arriving on the tailnet listener must carry the token; localhost is free."""
if request.host.startswith(f"{TAILNET_IP}:{REMOTE_PORT}") and OPERATOR_TOKEN:
auth = request.headers.get("X-Auth-Token", "")
# also accept ?t= for browser links
if not auth:
auth = request.args.get("t", "")
from hmac import compare_digest
if not compare_digest(auth, OPERATOR_TOKEN):
return ("not found", 404)
return None
def _run_remote():
app.run(host=TAILNET_IP, port=REMOTE_PORT, threaded=True, use_reloader=False)
if __name__ == "__main__":
import threading
threading.Thread(target=_run_remote, daemon=True).start()
app.run(host="127.0.0.1", port=8899, threaded=True)