conference_contacts.xlsx is now the single working file behind both the dash's Download button and the mail-merge tool -- populated with all 139 SPIE 2026 presenters (not just ones with emails already found), so Peter can fill in emails as he meets people at the conference. Also adds a second message template (reconnect_message) alongside the existing wavegate_focus one, and a search/scroll UI for the contacts list now that it's no longer a handful of rows.
263 lines
10 KiB
Python
263 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Local web app for the conference mail merge -- contacts with checkboxes,
|
|
a choice of standard message templates, and batch generate/send.
|
|
|
|
Run: python3 webapp.py
|
|
Then open http://localhost:8505 in a browser.
|
|
|
|
To enable the "Preview & Send Batch" -> actual sending step, set these
|
|
environment variables before starting the app:
|
|
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS
|
|
Without them, the app still works for generating .eml drafts -- sending
|
|
just stays disabled.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
from flask import Flask, render_template, request
|
|
|
|
import mail_merge as mm
|
|
|
|
HERE = Path(__file__).parent
|
|
MESSAGE_TEMPLATES_DIR = HERE / "message_templates"
|
|
DEFAULT_ATTACHMENTS = [
|
|
Path.home() / "Documents" / "TXproducts_Materials" / "txservices.pdf",
|
|
Path.home() / "Documents" / "TXproducts_Materials" / "wavegate.pdf",
|
|
]
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
def get_contacts():
|
|
if not mm.CONTACTS_FILE.exists():
|
|
return []
|
|
try:
|
|
df = pd.read_excel(mm.CONTACTS_FILE, sheet_name="Contacts")
|
|
except Exception:
|
|
return []
|
|
contacts = []
|
|
for _, row in df.iterrows():
|
|
conference = str(row.get("Conference", "")).strip()
|
|
conference = "" if conference.lower() == "nan" else conference
|
|
draft_opened = str(row.get("Draft Opened", "")).strip()
|
|
draft_opened = "" if draft_opened.lower() == "nan" else draft_opened
|
|
contacts.append({
|
|
"first": row.get("First Name", ""),
|
|
"last": row.get("Last Name", ""),
|
|
"email": row.get("Email", ""),
|
|
"conference": conference,
|
|
"sent": str(row.get("Materials Sent", "")).strip().lower() == "yes",
|
|
"draft_opened": draft_opened,
|
|
})
|
|
return contacts
|
|
|
|
|
|
def get_templates():
|
|
return sorted(p.name for p in MESSAGE_TEMPLATES_DIR.glob("*.txt"))
|
|
|
|
|
|
def get_attachment_options():
|
|
return [{"name": p.name, "path": str(p), "exists": p.exists()} for p in DEFAULT_ATTACHMENTS]
|
|
|
|
|
|
def rows_for_selection(selected_emails, template_name):
|
|
"""(row_dict, subject, body) for each selected contact, using the given template."""
|
|
df = pd.read_excel(mm.CONTACTS_FILE, sheet_name="Contacts")
|
|
wanted = {e.strip().lower() for e in selected_emails}
|
|
df = df[df["Email"].astype(str).str.strip().str.lower().isin(wanted)]
|
|
|
|
subject_template, body_template = mm.load_template(MESSAGE_TEMPLATES_DIR / template_name)
|
|
|
|
results = []
|
|
for _, row in df.iterrows():
|
|
row_dict = row.to_dict()
|
|
subject = mm.fill(subject_template, row_dict)
|
|
body = mm.fill(body_template, row_dict)
|
|
results.append((row_dict, subject, body))
|
|
return results
|
|
|
|
|
|
def smtp_configured():
|
|
return all(os.environ.get(k) for k in ("SMTP_HOST", "SMTP_USER", "SMTP_PASS"))
|
|
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return render_template(
|
|
"index.html",
|
|
contacts=get_contacts(),
|
|
templates=get_templates(),
|
|
attachments=get_attachment_options(),
|
|
default_from=mm.DEFAULT_FROM,
|
|
error=request.args.get("error"),
|
|
preselect_email=(request.args.get("email") or "").strip().lower(),
|
|
)
|
|
|
|
|
|
@app.route("/generate", methods=["POST"])
|
|
def generate():
|
|
selected = request.form.getlist("contacts")
|
|
template_name = request.form.get("template")
|
|
attachments = request.form.getlist("attachments")
|
|
from_address = request.form.get("from_address") or mm.DEFAULT_FROM
|
|
|
|
if not selected:
|
|
return render_template("index.html", contacts=get_contacts(), templates=get_templates(),
|
|
attachments=get_attachment_options(), default_from=mm.DEFAULT_FROM,
|
|
error="Check at least one contact first.")
|
|
|
|
try:
|
|
full_df = pd.read_excel(mm.CONTACTS_FILE, sheet_name="Contacts")
|
|
except Exception as exc:
|
|
return render_template("index.html", contacts=get_contacts(), templates=get_templates(),
|
|
attachments=get_attachment_options(), default_from=mm.DEFAULT_FROM,
|
|
error=f"Close the Excel file and try again. ({exc})")
|
|
if "Draft File" not in full_df.columns:
|
|
full_df["Draft File"] = ""
|
|
|
|
try:
|
|
rows = rows_for_selection(selected, template_name)
|
|
except ValueError as exc:
|
|
return render_template("index.html", contacts=get_contacts(), templates=get_templates(),
|
|
attachments=get_attachment_options(), default_from=mm.DEFAULT_FROM,
|
|
error=str(exc))
|
|
|
|
lines = []
|
|
warnings = []
|
|
for row_dict, subject, body in rows:
|
|
if "[[MISSING:" in subject or "[[MISSING:" in body:
|
|
warnings.append(f"Missing field(s) for {row_dict['Email']} -- check before sending.")
|
|
msg = mm.build_message(subject, body, row_dict, attachments, from_address)
|
|
out_path = mm.write_draft(msg, row_dict)
|
|
lines.append(f"Wrote draft: {out_path}")
|
|
|
|
match = full_df["Email"].astype(str).str.strip().str.lower() == str(row_dict["Email"]).strip().lower()
|
|
full_df.loc[match, "Draft File"] = str(out_path)
|
|
|
|
full_df.to_excel(mm.CONTACTS_FILE, index=False, sheet_name="Contacts")
|
|
mm.add_draft_links(mm.CONTACTS_FILE)
|
|
|
|
return render_template("results.html", lines=lines, warnings=warnings)
|
|
|
|
|
|
@app.route("/open_in_mail", methods=["POST"])
|
|
def open_in_mail():
|
|
selected = request.form.getlist("contacts")
|
|
template_name = request.form.get("template")
|
|
attachments = request.form.getlist("attachments")
|
|
from_address = request.form.get("from_address") or mm.DEFAULT_FROM
|
|
|
|
if not selected:
|
|
return render_template("index.html", contacts=get_contacts(), templates=get_templates(),
|
|
attachments=get_attachment_options(), default_from=mm.DEFAULT_FROM,
|
|
error="Check at least one contact first.")
|
|
|
|
try:
|
|
full_df = pd.read_excel(mm.CONTACTS_FILE, sheet_name="Contacts")
|
|
except Exception as exc:
|
|
return render_template("index.html", contacts=get_contacts(), templates=get_templates(),
|
|
attachments=get_attachment_options(), default_from=mm.DEFAULT_FROM,
|
|
error=f"Close the Excel file and try again. ({exc})")
|
|
if "Draft Opened" not in full_df.columns:
|
|
full_df["Draft Opened"] = ""
|
|
|
|
try:
|
|
rows = rows_for_selection(selected, template_name)
|
|
except ValueError as exc:
|
|
return render_template("index.html", contacts=get_contacts(), templates=get_templates(),
|
|
attachments=get_attachment_options(), default_from=mm.DEFAULT_FROM,
|
|
error=str(exc))
|
|
|
|
lines = []
|
|
warnings = []
|
|
opened_any = False
|
|
for row_dict, subject, body in rows:
|
|
if "[[MISSING:" in subject or "[[MISSING:" in body:
|
|
warnings.append(f"Missing field(s) for {row_dict['Email']} -- check before sending.")
|
|
try:
|
|
mm.open_in_mail_app(subject, body, row_dict["Email"], attachments, from_address)
|
|
lines.append(f"Opened in Mail.app: {row_dict['First Name']} {row_dict['Last Name']} <{row_dict['Email']}>")
|
|
timestamp = datetime.now().strftime("%d.%m.%y %H:%M")
|
|
match = full_df["Email"].astype(str).str.strip().str.lower() == str(row_dict["Email"]).strip().lower()
|
|
full_df.loc[match, "Draft Opened"] = timestamp
|
|
opened_any = True
|
|
mm.log_to_dash_outreach(row_dict["Email"], f"Draft opened in Mail.app ({template_name}), {timestamp}")
|
|
except RuntimeError as exc:
|
|
warnings.append(f"Could not open Mail.app draft for {row_dict['Email']}: {exc}")
|
|
|
|
if opened_any:
|
|
full_df.to_excel(mm.CONTACTS_FILE, index=False, sheet_name="Contacts")
|
|
|
|
return render_template("results.html", lines=lines, warnings=warnings)
|
|
|
|
|
|
@app.route("/preview", methods=["POST"])
|
|
def preview():
|
|
selected = request.form.getlist("contacts")
|
|
template_name = request.form.get("template")
|
|
attachments = request.form.getlist("attachments")
|
|
from_address = request.form.get("from_address") or mm.DEFAULT_FROM
|
|
|
|
if not selected:
|
|
return render_template("index.html", contacts=get_contacts(), templates=get_templates(),
|
|
attachments=get_attachment_options(), default_from=mm.DEFAULT_FROM,
|
|
error="Check at least one contact first.")
|
|
|
|
try:
|
|
rows = rows_for_selection(selected, template_name)
|
|
except ValueError as exc:
|
|
return render_template("index.html", contacts=get_contacts(), templates=get_templates(),
|
|
attachments=get_attachment_options(), default_from=mm.DEFAULT_FROM,
|
|
error=str(exc))
|
|
|
|
table_rows = [{"email": r["Email"], "subject": s, "body": b} for r, s, b in rows]
|
|
|
|
return render_template(
|
|
"preview.html",
|
|
rows=table_rows,
|
|
smtp_ready=smtp_configured(),
|
|
contacts=selected,
|
|
template=template_name,
|
|
attachments=attachments,
|
|
from_address=from_address,
|
|
)
|
|
|
|
|
|
@app.route("/send", methods=["POST"])
|
|
def send():
|
|
if not smtp_configured():
|
|
return render_template("send_results.html", lines=["SMTP isn't configured -- nothing was sent."])
|
|
|
|
selected = request.form.getlist("contacts")
|
|
template_name = request.form.get("template")
|
|
attachments = request.form.getlist("attachments")
|
|
from_address = request.form.get("from_address") or mm.DEFAULT_FROM
|
|
|
|
rows = rows_for_selection(selected, template_name)
|
|
lines = []
|
|
for row_dict, subject, body in rows:
|
|
msg = mm.build_message(subject, body, row_dict, attachments, from_address)
|
|
try:
|
|
mm.send_message(msg)
|
|
lines.append(f"Sent to {row_dict['Email']}")
|
|
except Exception as exc:
|
|
lines.append(f"FAILED for {row_dict['Email']}: {exc}")
|
|
|
|
return render_template("send_results.html", lines=lines)
|
|
|
|
|
|
@app.route("/open_folder", methods=["POST"])
|
|
def open_folder():
|
|
mm.OUTPUT_DIR.mkdir(exist_ok=True)
|
|
subprocess.run(["open", str(mm.OUTPUT_DIR)])
|
|
return ("", 204)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(port=8505, debug=False)
|