← back to Rentv Sheet Enrich
Finish send mechanics: real Purelymail SMTP send (smtplib SSL) with idempotent sent-log, warmup cap, env-only password; all guards verified (dry-run default, placeholder-abort, from/pass-required). Sends nothing without explicit --send+message+creds
704267696228fc4a74b6afa6f7d882db912b9bc0 · 2026-08-14 15:19:24 -0700 · Steve Abrams
Files touched
A outreach/__pycache__/rentv_send_purelymail.cpython-314.pycM outreach/rentv_send_purelymail.py
Diff
commit 704267696228fc4a74b6afa6f7d882db912b9bc0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 14 15:19:24 2026 -0700
Finish send mechanics: real Purelymail SMTP send (smtplib SSL) with idempotent sent-log, warmup cap, env-only password; all guards verified (dry-run default, placeholder-abort, from/pass-required). Sends nothing without explicit --send+message+creds
---
.../rentv_send_purelymail.cpython-314.pyc | Bin 0 -> 10174 bytes
outreach/rentv_send_purelymail.py | 139 +++++++++++++--------
2 files changed, 89 insertions(+), 50 deletions(-)
diff --git a/outreach/__pycache__/rentv_send_purelymail.cpython-314.pyc b/outreach/__pycache__/rentv_send_purelymail.cpython-314.pyc
new file mode 100644
index 0000000..113521f
Binary files /dev/null and b/outreach/__pycache__/rentv_send_purelymail.cpython-314.pyc differ
diff --git a/outreach/rentv_send_purelymail.py b/outreach/rentv_send_purelymail.py
index 638985c..4da884c 100644
--- a/outreach/rentv_send_purelymail.py
+++ b/outreach/rentv_send_purelymail.py
@@ -1,76 +1,115 @@
#!/usr/bin/env python3
"""
rentv_send_purelymail.py — STAGED $0 send path for the RENTV commercial-RE audience
-via Purelymail SMTP. DRY-RUN BY DEFAULT. Sending is a hard-gated bulk action — this
-script NEVER sends unless run with --send AND --i-am-steve AND a from-address is set.
+via Purelymail SMTP. DRY-RUN BY DEFAULT. Sending is a hard-gated bulk action.
-Guardrails baked in (Steve's standing rules + the deliverability panel):
+Guardrails (Steve's standing rules + the deliverability panel):
* DRY-RUN default: prints exactly what it WOULD send, sends nothing.
- * WARMUP-BATCHED: --per-day caps the daily volume (default 40) — you do NOT blast
- 2,636 cold emails at once from a low-volume domain; that gets the domain spam-flagged
- before the good leads ever land. Ramp over ~2 months.
- * VERIFIED-ONLY: refuses to load the inferred/guessed list — only the verified audience.
- * CAN-SPAM footer is MANDATORY (physical address + one-click opt-out) or it aborts.
- * FROM address must be an explicit real RENTV/Steven-Bloom mailbox you control — no default.
+ * --send requires --i-am-steve too (a human-intent handshake, not an accident).
+ * VERIFIED-ONLY: loads rentv_outreach_READY_verified.csv only (never the guessed list).
+ * WARMUP-BATCHED: --per-day caps daily volume (default 40); a low-volume domain that
+ blasts thousands cold gets spam-flagged before the good leads land.
+ * IDEMPOTENT: every send is appended to data/sent.log; re-runs SKIP already-sent
+ recipients and count today's sends against the cap — safe to re-run, never doubles.
+ * CAN-SPAM footer (physical address + opt-out) is MANDATORY or it aborts.
+ * MESSAGE must not be the DRAFT placeholder; FROM must be an explicit real mailbox.
+ * SMTP password comes from an env var (never a CLI arg / never printed).
Usage:
- python3 rentv_send_purelymail.py # dry-run, shows plan + sample
- python3 rentv_send_purelymail.py --send --i-am-steve --from steven@rentv... --per-day 40
+ python3 rentv_send_purelymail.py # dry-run plan + sample
+ SMTP_PASS_ENV=PURELYMAIL_..._PASSWORD \\
+ python3 rentv_send_purelymail.py --send --i-am-steve \\
+ --from steven@rentv.example --per-day 40 # real, capped, gated
"""
-import csv, sys, os
+import csv, sys, os, ssl, smtplib, datetime
+from email.message import EmailMessage
-READY = os.path.join(os.path.dirname(__file__), "..", "rentv_outreach_READY_verified.csv")
-FROM = None
-for i,a in enumerate(sys.argv):
- if a == "--from" and i+1 < len(sys.argv): FROM = sys.argv[i+1]
-SEND = "--send" in sys.argv and "--i-am-steve" in sys.argv
-PER_DAY = 40
-for i,a in enumerate(sys.argv):
- if a == "--per-day" and i+1 < len(sys.argv): PER_DAY = int(sys.argv[i+1])
+HERE = os.path.dirname(os.path.abspath(__file__))
+READY = os.path.join(HERE, "..", "rentv_outreach_READY_verified.csv")
+SENTLOG = os.path.join(HERE, "data", "sent.log")
+SMTP_HOST, SMTP_PORT = "smtp.purelymail.com", 465
+
+def arg(name, default=None):
+ if name in sys.argv:
+ i = sys.argv.index(name)
+ return sys.argv[i+1] if i+1 < len(sys.argv) else True
+ return default
+
+FROM = arg("--from")
+SEND = ("--send" in sys.argv) and ("--i-am-steve" in sys.argv)
+PER_DAY = int(arg("--per-day", 40))
+PASS_ENV = os.environ.get("SMTP_PASS_ENV") # name of the env var holding the mailbox password
-# ---- MANDATORY CAN-SPAM footer (physical address + opt-out) ----
FOOTER = ("\n\n—\nSteven Bloom · Founder & CEO, RENTV\n"
"5757 W. Century Blvd, Suite 700, Los Angeles, CA 90045 · (310) 213-6409\n"
"You're receiving this as a commercial-real-estate professional in the RENTV network. "
"If you'd prefer I not email you, just reply STOP and I won't follow up.")
-
-# ---- DRAFT template (SUBJECT + BODY) — Steve must review/replace the message ----
SUBJECT = "[DRAFT — replace me] Quick note from RENTV"
-BODY = ("Hi {first},\n\n[DRAFT PLACEHOLDER — this is not the real message. Steve must define the "
- "actual purpose: RENTV platform intro / event invite / sublease-desk / newsletter — then "
- "personalize per-recipient. Do not send this placeholder.]\n\nBest,\nSteven")
+BODY = ("Hi {first},\n\n[DRAFT PLACEHOLDER — not the real message. Define the purpose "
+ "(RENTV platform intro / event invite / sublease-desk / newsletter), then personalize.]\n\nBest,\nSteven")
def load_verified():
with open(READY) as f:
- rows = list(csv.DictReader(f))
- # hard guard: every recipient must have an email; this file is verified-only by construction
- return [r for r in rows if r.get("Email")]
+ return [r for r in csv.DictReader(f) if r.get("Email")]
+
+def already_sent():
+ s = set()
+ if os.path.exists(SENTLOG):
+ for line in open(SENTLOG):
+ parts = line.strip().split("\t")
+ if len(parts) >= 2: s.add(parts[1].lower())
+ return s
+
+def sent_today():
+ if not os.path.exists(SENTLOG): return 0
+ today = datetime.date.today().isoformat()
+ return sum(1 for line in open(SENTLOG) if line.startswith(today))
+
+def render(r):
+ first = (r["Contact Name"].split() or ["there"])[0]
+ return SUBJECT, BODY.format(first=first) + FOOTER
def main():
rows = load_verified()
- print(f"Audience: {len(rows)} VERIFIED commercial-RE leads (from rentv_outreach_READY_verified.csv)")
- print(f"Warmup cap: {PER_DAY}/day -> ~{-(-len(rows)//PER_DAY)} days to send the full list")
- print(f"Footer (CAN-SPAM): present ✔ | From: {FROM or 'NOT SET (required)'}")
- print(f"Subject: {SUBJECT}")
- if "[DRAFT" in SUBJECT or "[DRAFT" in BODY:
- print("\n⚠ MESSAGE IS A PLACEHOLDER — Steve must define/approve the real subject+body before any send.")
- # show a sample rendered email
- r = rows[0]; first = (r['Contact Name'].split() or ['there'])[0]
- print("\n--- SAMPLE (rendered, top-warmth recipient) ---")
- print(f"To: {r['Email']} ({r['Contact Name']} · {r['Company']})")
- print(f"Subject: {SUBJECT}")
- print(BODY.format(first=first) + FOOTER)
- print("--- END SAMPLE ---")
+ done = already_sent()
+ todo = [r for r in rows if r["Email"].lower() not in done]
+ placeholder = ("[DRAFT" in SUBJECT) or ("[DRAFT" in BODY)
+ print(f"Audience: {len(rows)} verified CRE | already sent: {len(done)} | remaining: {len(todo)}")
+ print(f"Warmup cap: {PER_DAY}/day | sent today: {sent_today()} | From: {FROM or 'NOT SET'} | SMTP pass env: {PASS_ENV or 'NOT SET'}")
+ if placeholder:
+ print("⚠ MESSAGE IS A PLACEHOLDER — replace SUBJECT/BODY before any real send.")
+ if todo:
+ s, b = render(todo[0]); r = todo[0]
+ print(f"\n--- SAMPLE ---\nTo: {r['Email']} ({r['Contact Name']} · {r['Company']})\nSubject: {s}\n{b}\n--- END ---")
+
if not SEND:
- print("\nDRY-RUN — nothing sent. To actually send you must: (1) Steve replaces the DRAFT message,")
- print("(2) set --from to a real RENTV mailbox you control, (3) re-run with --send --i-am-steve,")
- print("(4) it will send AT MOST --per-day today. Sending is Steve's gated call.")
+ print("\nDRY-RUN — nothing sent. Real send needs: replace the DRAFT message, --from a real mailbox,")
+ print("SMTP_PASS_ENV=<the mailbox's Purelymail password env var>, then --send --i-am-steve.")
return
- if "[DRAFT" in SUBJECT or "[DRAFT" in BODY:
- print("\nABORT: refusing to send a placeholder message."); return
- if not FROM:
- print("\nABORT: --from not set."); return
- print("\n[send path would run here — intentionally not implemented in this staged scaffold]")
+ # --- real send path (all guards must pass) ---
+ if placeholder: print("\nABORT: refusing to send the placeholder message."); return
+ if not FROM: print("\nABORT: --from not set."); return
+ if not PASS_ENV or not os.environ.get(PASS_ENV):
+ print("\nABORT: SMTP_PASS_ENV not set or that env var is empty (need the mailbox's Purelymail password)."); return
+ password = os.environ[PASS_ENV]
+ remaining_today = max(0, PER_DAY - sent_today())
+ batch = todo[:remaining_today]
+ if not batch:
+ print(f"\nDaily cap reached ({sent_today()}/{PER_DAY}). Re-run tomorrow to continue."); return
+ print(f"\nSending {len(batch)} now (cap {PER_DAY}/day, {len(todo)-len(batch)} deferred)...")
+ os.makedirs(os.path.dirname(SENTLOG), exist_ok=True)
+ ctx = ssl.create_default_context()
+ with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=ctx) as smtp:
+ smtp.login(FROM, password)
+ with open(SENTLOG, "a") as log:
+ for r in batch:
+ s, b = render(r)
+ msg = EmailMessage(); msg["From"] = FROM; msg["To"] = r["Email"]
+ msg["Subject"] = s; msg.set_content(b)
+ smtp.send_message(msg)
+ log.write(f"{datetime.datetime.now().isoformat()}\t{r['Email']}\t{r['Company']}\n"); log.flush()
+ print(f" sent -> {r['Email']}")
+ print(f"Done. {len(batch)} sent, logged to {SENTLOG}.")
if __name__ == "__main__":
main()
← 0f44288 Stage $0 Purelymail send path for 2636 verified CRE audience
·
back to Rentv Sheet Enrich
·
Draft real RENTV outreach messages: 3 grounded variants (net e1e9fbe →