#!/usr/bin/env python3 """ Hermithire Outreach Campaign Engine Sends beautiful HTML emails via Zoho Mail SMTP. Tracks sent/replied status in tracking/log.json. """ import os, sys, csv, json, time, smtplib, logging, argparse from datetime import datetime, date from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from pathlib import Path # ── Paths ─────────────────────────────────────────────────────────────────── BASE = Path(__file__).parent CONTACTS_FILE = BASE / 'contacts' / 'master.csv' LOG_FILE = BASE / 'tracking' / 'log.json' TEMPLATE_1 = BASE / 'templates' / 'email_1_initial.html' TEMPLATE_2 = BASE / 'templates' / 'email_2_followup.html' ENV_FILE = BASE / '.env' # ── Logging ────────────────────────────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format='%(asctime)s %(levelname)-8s %(message)s', handlers=[ logging.FileHandler(BASE / 'tracking' / 'campaign.log'), logging.StreamHandler(sys.stdout) ] ) log = logging.getLogger(__name__) # ── Load .env ──────────────────────────────────────────────────────────────── def load_env(): if ENV_FILE.exists(): with open(ENV_FILE) as f: for line in f: line = line.strip() if line and not line.startswith('#') and '=' in line: k, v = line.split('=', 1) os.environ.setdefault(k.strip(), v.strip()) load_env() SMTP_SERVER = os.environ.get('SMTP_SERVER', 'smtp.zoho.eu') SMTP_PORT = int(os.environ.get('SMTP_PORT', '587')) SMTP_USER = os.environ.get('SMTP_USERNAME', '') SMTP_PASS = os.environ.get('SMTP_PASSWORD', '') FROM_NAME = os.environ.get('FROM_NAME', 'Hermithire') FROM_EMAIL = os.environ.get('FROM_EMAIL', SMTP_USER) REPLY_TO = os.environ.get('REPLY_TO', FROM_EMAIL) BATCH_SIZE = int(os.environ.get('BATCH_SIZE', '80')) DELAY = float(os.environ.get('DELAY_SECONDS', '8')) UNSUBSCRIBE_URL = 'https://hermithire.com/unsubscribe' # ── Tracking log ───────────────────────────────────────────────────────────── def load_log() -> dict: LOG_FILE.parent.mkdir(parents=True, exist_ok=True) if LOG_FILE.exists(): with open(LOG_FILE) as f: return json.load(f) return {} def save_log(data: dict): with open(LOG_FILE, 'w') as f: json.dump(data, f, indent=2, default=str) # ── Contact CSV ─────────────────────────────────────────────────────────────── def load_contacts(segment_filter=None) -> list[dict]: contacts = [] with open(CONTACTS_FILE, newline='', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: if not row.get('email', '').strip(): continue if row.get('skip', '').strip().lower() in ('yes', 'true', '1'): continue if segment_filter and row.get('segment', '') != segment_filter: continue contacts.append(row) return contacts # ── Template rendering ──────────────────────────────────────────────────────── def render_template(template_path: Path, contact: dict) -> str: with open(template_path, encoding='utf-8') as f: html = f.read() first_name = contact.get('first_name') or contact.get('name', '').split()[0] or 'there' org = contact.get('organisation', contact.get('org', '')) replacements = { '{{first_name}}': first_name.strip(), '{{full_name}}': contact.get('name', first_name).strip(), '{{organisation}}': org.strip(), '{{segment}}': contact.get('segment', '').strip(), '{{unsubscribe_url}}': f"{UNSUBSCRIBE_URL}?email={contact['email'].strip()}", '{{email}}': contact.get('email', '').strip(), } for token, value in replacements.items(): html = html.replace(token, value) return html # ── Subject lines ───────────────────────────────────────────────────────────── SUBJECTS_1 = { 'press': 'Story tip: the ornamental hermit marketplace (it\'s real)', 'heritage': 'A proposal regarding the ornamental hermit tradition', 'philosophy': 'An unusual invitation — and a genuine one', 'history': 'The ornamental hermit: now commercially available again', 'lifestyle': 'The rarest estate amenity money can\'t buy (yet)', 'community': 'Would you like to become an ornamental hermit?', 'substack': 'Ready-to-share: the hermit marketplace that shouldn\'t exist', 'influencer': 'The strangest collaboration pitch you\'ll receive this year', 'local': 'Local news: the ornamental hermit revival', 'humour': 'Quit your job. Live in a cave. Get paid. (This is real.)', 'anti-work': 'The only job where doing nothing is the job description', 'weird-internet':'We are paying people to be hermits. Yes, really.', 'ai-tech': 'AI took the jobs. Billionaires are now hiring humans as garden ornaments.', 'blue-collar': 'When robots take your job, the rich hire you to sit in their garden', 'yoga': 'The rare invitation for those who already understand stillness', 'naturopathy': 'An unusual practice that predates yours by several centuries', 'earthy': 'A role for those who already live closer to the land', 'default': 'An unusual invitation from Hermithire', } SUBJECTS_2 = { 'press': 'Re: the hermit story — still available if you\'d like it', 'heritage': 'A gentle follow-up on our ornamental hermit correspondence', 'philosophy': 'We understand. Hermits are patient. (A follow-up.)', 'history': 'Following up — the hermit marketplace, and a little more history', 'lifestyle': 'Still seeking hermits. Still unusual. Still real.', 'community': 'You may have missed this. We understand. Hermits are patient.', 'substack': 'Following up — the hermit story, with more detail', 'influencer': 'Still seeking hermits — and still the strangest pitch of 2026', 'local': 'Following up: the hermit revival — an update', 'humour': 'Following up: the hermit job (no, we didn\'t make it up)', 'anti-work': 'Re: the job where you do nothing (a gentle follow-up)', 'weird-internet':'Still paying people to be hermits. Still real. (Follow-up)', 'ai-tech': 'Re: AI takes jobs / billionaires hire ornamental humans (follow-up)', 'blue-collar': 'Re: the garden hermit job — still the most honest work around', 'yoga': 'A follow-up — the invitation for those who already know silence', 'naturopathy': 'Following up — the ancient practice, still seeking practitioners', 'earthy': 'Following up — the hermit role, for those already close to the land', 'default': 'A second note from Hermithire (our last on this matter)', } def get_subject(touch: int, segment: str) -> str: lookup = SUBJECTS_1 if touch == 1 else SUBJECTS_2 return lookup.get(segment, lookup['default']) # ── SMTP connection ─────────────────────────────────────────────────────────── def get_smtp(): if not SMTP_USER or not SMTP_PASS: log.error('SMTP credentials not set. Copy .env.example to .env and fill in details.') sys.exit(1) server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT) server.ehlo() server.starttls() server.ehlo() server.login(SMTP_USER, SMTP_PASS) log.info(f'Connected to {SMTP_SERVER}:{SMTP_PORT} as {SMTP_USER}') return server # ── Send one email ──────────────────────────────────────────────────────────── def send_email(server: smtplib.SMTP, contact: dict, touch: int, dry_run: bool = False) -> bool: email_addr = contact['email'].strip() segment = contact.get('segment', 'default').strip() name = contact.get('name', email_addr) template = TEMPLATE_1 if touch == 1 else TEMPLATE_2 html_body = render_template(template, contact) subject = get_subject(touch, segment) msg = MIMEMultipart('alternative') msg['Subject'] = subject msg['From'] = f'{FROM_NAME} <{FROM_EMAIL}>' msg['To'] = f'{name} <{email_addr}>' msg['Reply-To'] = REPLY_TO msg.add_header('List-Unsubscribe', f'<{UNSUBSCRIBE_URL}?email={email_addr}>') msg.add_header('X-Campaign', f'hermithire-touch-{touch}') plain = ( f"Dear {contact.get('first_name', name)},\n\n" f"We are writing about an unusual opportunity: the chance to become an " f"ornamental hermit with Hermithire, a curated marketplace reviving the " f"18th-century tradition of hiring individuals to live in contemplative " f"solitude on private estates.\n\n" f"Read more and apply at: https://hermithire.com/join\n\n" f"View our current hermits: https://hermithire.com/collections\n\n" f"With respect for your time,\nThe Hermithire Collection\nhermithire.com\n\n" f"Unsubscribe: {UNSUBSCRIBE_URL}?email={email_addr}" ) msg.attach(MIMEText(plain, 'plain')) msg.attach(MIMEText(html_body, 'html')) if dry_run: log.info(f'[DRY RUN] Would send touch {touch} to {email_addr} ({segment})') return True try: server.sendmail(FROM_EMAIL, [email_addr], msg.as_string()) log.info(f'✓ Sent touch {touch} → {email_addr} ({name}, {segment})') return True except smtplib.SMTPException as e: log.error(f'✗ Failed to send to {email_addr}: {e}') return False # ── Main campaign logic ─────────────────────────────────────────────────────── def run_campaign(touch: int, segment: str = None, dry_run: bool = False, limit: int = None, resume_after: str = None): log.info(f'═══ Hermithire Campaign | Touch {touch} | {"DRY RUN" if dry_run else "LIVE"} ═══') contacts = load_contacts(segment_filter=segment) tracking = load_log() today = date.today().isoformat() to_send = [] for c in contacts: email = c['email'].strip() rec = tracking.get(email, {}) if touch == 1: if rec.get('touch_1_sent'): continue # already sent touch 1 elif touch == 2: if not rec.get('touch_1_sent'): continue # haven't sent touch 1 yet if rec.get('touch_2_sent'): continue # already sent touch 2 if rec.get('replied'): continue # they replied — stop sent_date = rec.get('touch_1_date', '') if sent_date: from datetime import date as d delta = (d.today() - d.fromisoformat(sent_date)).days if delta < 7: continue # too soon to_send.append(c) # Resume from a specific email (useful if a batch was interrupted) if resume_after: emails = [c['email'].strip() for c in to_send] if resume_after in emails: idx = emails.index(resume_after) + 1 to_send = to_send[idx:] log.info(f'Resuming after {resume_after} ({len(to_send)} remaining)') if limit: to_send = to_send[:limit] log.info(f'Contacts to send: {len(to_send)} (batch limit: {BATCH_SIZE})') to_send = to_send[:BATCH_SIZE] if not to_send: log.info('Nothing to send. All contacts up to date.') return if not dry_run: server = get_smtp() else: server = None sent_count = 0 for i, contact in enumerate(to_send): email = contact['email'].strip() # Reconnect if SMTP connection has dropped if not dry_run: try: server.noop() except Exception: log.info('SMTP connection dropped — reconnecting…') try: server.quit() except Exception: pass server = get_smtp() ok = send_email(server, contact, touch, dry_run=dry_run) if ok and not dry_run: if email not in tracking: tracking[email] = {} tracking[email][f'touch_{touch}_sent'] = True tracking[email][f'touch_{touch}_date'] = today tracking[email]['name'] = contact.get('name', '') tracking[email]['segment'] = contact.get('segment', '') save_log(tracking) sent_count += 1 if i < len(to_send) - 1: time.sleep(DELAY) if not dry_run and server: server.quit() log.info(f'═══ Done. Sent {sent_count} emails. ═══') print_summary(tracking) # ── Mark a reply (run manually when someone replies) ───────────────────────── def mark_replied(email: str, note: str = ''): tracking = load_log() if email not in tracking: tracking[email] = {} tracking[email]['replied'] = True tracking[email]['replied_date'] = date.today().isoformat() tracking[email]['reply_note'] = note save_log(tracking) log.info(f'Marked {email} as replied. Follow-ups suppressed.') # ── Summary report ──────────────────────────────────────────────────────────── def print_summary(tracking: dict = None): if tracking is None: tracking = load_log() total = len(tracking) touch1 = sum(1 for r in tracking.values() if r.get('touch_1_sent')) touch2 = sum(1 for r in tracking.values() if r.get('touch_2_sent')) replied = sum(1 for r in tracking.values() if r.get('replied')) contacts = load_contacts() remaining_1 = sum( 1 for c in contacts if not tracking.get(c['email'].strip(), {}).get('touch_1_sent') ) print('\n┌─────────────────────────────────────────┐') print('│ HERMITHIRE CAMPAIGN SUMMARY │') print('├─────────────────────────────────────────┤') print(f'│ Total in database: {len(contacts):>6} │') print(f'│ Touch 1 sent: {touch1:>6} │') print(f'│ Touch 2 sent: {touch2:>6} │') print(f'│ Replied: {replied:>6} │') print(f'│ Still to send (T1): {remaining_1:>6} │') print('└─────────────────────────────────────────┘\n') # ── CLI ─────────────────────────────────────────────────────────────────────── if __name__ == '__main__': parser = argparse.ArgumentParser(description='Hermithire Campaign Engine') sub = parser.add_subparsers(dest='command') # send p_send = sub.add_parser('send', help='Send campaign emails') p_send.add_argument('--touch', type=int, default=1, choices=[1,2], help='Which email touch to send') p_send.add_argument('--segment', type=str, default=None, help='Filter by segment (press/heritage/etc)') p_send.add_argument('--dry-run', action='store_true', help='Print what would be sent without sending') p_send.add_argument('--limit', type=int, default=None, help='Max contacts to process this run') p_send.add_argument('--resume-after', type=str, default=None, help='Email address to resume after') # status p_stat = sub.add_parser('status', help='Show campaign summary') # replied p_rep = sub.add_parser('replied', help='Mark a contact as having replied') p_rep.add_argument('email', help='Email address that replied') p_rep.add_argument('--note', type=str, default='', help='Optional note about the reply') args = parser.parse_args() if args.command == 'send': run_campaign( touch=args.touch, segment=args.segment, dry_run=args.dry_run, limit=args.limit, resume_after=args.resume_after, ) elif args.command == 'status': print_summary() elif args.command == 'replied': mark_replied(args.email, args.note) else: parser.print_help()