Add SPIE conference mail-merge tool integration and Compose Email flow
Wires the Sales & Marketing dash's presenter cards directly into the mail-merge tool: Compose Email now opens a real Mail.app draft via localhost:8505 (merged template, attachments, signature intact), and logs the outreach automatically back into both conference_contacts.xlsx and the presenter's Outreach Log.
This commit is contained in:
1 parent
8f1b40351b
commit
b0360b6b68
18 files changed
+1979
-259
No files matched your search
@@ -1,3 +1,12 @@
|
||||
.DS_Store
|
||||
nextcloud_export/
|
||||
*_Snapshot.html
|
||||
.env
|
||||
*.env
|
||||
address_book/conference_mail_merge/conference_contacts.xlsx
|
||||
address_book/conference_mail_merge/output_drafts/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.Rhistory
|
||||
dash_launchd.log
|
||||
dash_launchd_error.log
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
echo "Starting Mail Merge web app..."
|
||||
python3 webapp.py &
|
||||
SERVER_PID=$!
|
||||
sleep 1
|
||||
open http://localhost:8505
|
||||
echo "Running at http://localhost:8505 -- close this window (or Ctrl+C) to stop it."
|
||||
wait $SERVER_PID
|
||||
@@ -0,0 +1,82 @@
|
||||
# Conference Contact Mail Merge
|
||||
|
||||
Personalizes and drafts follow-up emails for people met at conferences, pulling
|
||||
fields from an Excel sheet. No CRM yet, so this is plain Excel + Python --
|
||||
`conference_contacts.xlsx` can be uploaded to Nextcloud once it's filled in,
|
||||
for anyone else to see/update (there's no live desktop sync set up, so that's
|
||||
a manual upload-after-editing step, not automatic).
|
||||
|
||||
## Files
|
||||
|
||||
- `conference_contacts.xlsx` -- one row per contact. Columns: First Name,
|
||||
Last Name, Email, Company, Role, Conference, Date Met, Notes, Materials
|
||||
Sent, Date Sent. Add as many rows as you like. A "Draft File" column gets
|
||||
added/filled in automatically after each run (draft mode only, not
|
||||
`--send`) -- click the link in that cell to jump straight to that
|
||||
contact's `.eml`. Note: each run rewrites the whole sheet to fill this in,
|
||||
which resets any manual formatting (column widths, colors, etc.) you've
|
||||
added in Excel.
|
||||
- `email_template.txt` -- the email text. First line must start with
|
||||
`SUBJECT:`. Any `{{Column Name}}` in the subject or body gets replaced with
|
||||
that contact's value from the matching Excel column.
|
||||
- `mail_merge.py` -- fills the template per row. No signature is built or
|
||||
embedded -- whatever signature your mail app (or the mail server) adds is
|
||||
separate from this, and won't apply to the `--send` path (see below).
|
||||
|
||||
## Usage
|
||||
|
||||
1. Fill in `conference_contacts.xlsx` with real contacts (keep the column
|
||||
headers as they are, since the template matches on those names).
|
||||
2. Edit `email_template.txt` if you want to change the wording. Add new
|
||||
`{{Column Name}}` placeholders as long as a matching column exists.
|
||||
3. Run it from Terminal, in this folder:
|
||||
|
||||
```
|
||||
python3 mail_merge.py --attach ~/Documents/TXproducts_Materials/txservices.pdf ~/Documents/TXproducts_Materials/wavegate.pdf
|
||||
```
|
||||
|
||||
This writes one `.eml` file per contact into `output_drafts/`. Double-click
|
||||
a `.eml` to open it as a draft in Mail.app/Outlook/whatever your default
|
||||
mail app is -- read it over, make any last personal tweak, and hit send
|
||||
yourself. **Nothing is sent automatically with this mode.**
|
||||
|
||||
4. Once you've actually sent one, mark it in the Excel sheet
|
||||
("Materials Sent" = Yes, fill in "Date Sent") so a re-run skips it.
|
||||
|
||||
### Useful flags
|
||||
|
||||
- `--attach file1.pdf file2.pdf` -- attach one or more files (space-separated).
|
||||
- `--from-address someone@example.com` -- sets the "From" on the `.eml` draft,
|
||||
so Mail.app picks the right account if you have more than one configured.
|
||||
Defaults to `mblack@txproducts.de`.
|
||||
- `--only email1@x.com email2@y.com` -- only generate drafts for these
|
||||
specific addresses, skipping everyone else currently in the sheet. Leave
|
||||
this off to process every row that isn't marked "Materials Sent: Yes".
|
||||
|
||||
## Optional: direct sending via SMTP
|
||||
|
||||
If you'd rather send straight from the script instead of via `.eml` drafts,
|
||||
set these environment variables and run with `--send`. Port matters: use
|
||||
`465` for implicit SSL, `587` for STARTTLS -- the script picks the right
|
||||
handshake automatically based on which port you give it.
|
||||
|
||||
```
|
||||
export SMTP_HOST=...
|
||||
export SMTP_PORT=587
|
||||
export SMTP_USER=you@example.com
|
||||
export SMTP_PASS=...
|
||||
python3 mail_merge.py --send
|
||||
```
|
||||
|
||||
It shows you a preview of each email and asks `[y/N]` before sending it --
|
||||
nothing goes out without a per-email confirmation. `--send` uses `SMTP_USER`
|
||||
as the From address, ignoring `--from-address`.
|
||||
|
||||
## Note on contacting these people
|
||||
|
||||
These are people you met and spoke with in person at a conference, which is a
|
||||
different situation from the boss's cold Phase 1 target list (LinkedIn-only,
|
||||
personal note required, see the Address Book README) -- an in-person
|
||||
introduction is a much stronger legitimate-interest basis for a follow-up
|
||||
email. Still worth keeping things genuinely personal per contact rather than
|
||||
sending an identical form letter to everyone.
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
SUBJECT: Great meeting you at {{Conference}}, {{First Name}}!
|
||||
|
||||
Hi {{First Name}}, we met on {{Date Met}} at the {{Conference}}. Attached
|
||||
here is the information material I promised.
|
||||
|
||||
Looking forward to hearing from you.
|
||||
|
||||
Best regards,
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple checkbox window for the conference mail merge, so contacts can be
|
||||
picked with clicks instead of typing --only on the command line.
|
||||
|
||||
Run it directly (python3 gui.py) or double-click "Launch Mail Merge.command"
|
||||
on the Desktop.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
import pandas as pd
|
||||
|
||||
import mail_merge as mm
|
||||
|
||||
DEFAULT_ATTACHMENTS = [
|
||||
Path.home() / "Documents" / "TXproducts_Materials" / "txservices.pdf",
|
||||
Path.home() / "Documents" / "TXproducts_Materials" / "wavegate.pdf",
|
||||
]
|
||||
|
||||
|
||||
class MailMergeApp(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("TXproducts Mail Merge")
|
||||
self.geometry("640x560")
|
||||
|
||||
self.contact_vars = [] # list of (tk.BooleanVar, row_dict)
|
||||
self.attachment_vars = [] # list of (tk.BooleanVar, Path)
|
||||
|
||||
self._build_widgets()
|
||||
self.refresh_contacts()
|
||||
|
||||
def _build_widgets(self):
|
||||
top = ttk.Frame(self, padding=10)
|
||||
top.pack(fill="x")
|
||||
|
||||
ttk.Label(top, text="From address:").grid(row=0, column=0, sticky="w")
|
||||
self.from_var = tk.StringVar(value=mm.DEFAULT_FROM)
|
||||
ttk.Entry(top, textvariable=self.from_var, width=40).grid(row=0, column=1, sticky="w", padx=6)
|
||||
|
||||
ttk.Label(self, text="Contacts (check who gets a draft):", padding=(10, 8, 10, 0)).pack(anchor="w")
|
||||
|
||||
list_frame = ttk.Frame(self, padding=(10, 0))
|
||||
list_frame.pack(fill="both", expand=True)
|
||||
|
||||
canvas = tk.Canvas(list_frame, borderwidth=0)
|
||||
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
|
||||
self.contacts_frame = ttk.Frame(canvas)
|
||||
self.contacts_frame.bind(
|
||||
"<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
)
|
||||
canvas.create_window((0, 0), window=self.contacts_frame, anchor="nw")
|
||||
canvas.configure(yscrollcommand=scrollbar.set)
|
||||
canvas.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
btn_row = ttk.Frame(self, padding=10)
|
||||
btn_row.pack(fill="x")
|
||||
ttk.Button(btn_row, text="Select All", command=lambda: self._set_all(True)).pack(side="left")
|
||||
ttk.Button(btn_row, text="Select None", command=lambda: self._set_all(False)).pack(side="left", padx=6)
|
||||
ttk.Button(btn_row, text="Refresh from Excel", command=self.refresh_contacts).pack(side="left", padx=6)
|
||||
|
||||
ttk.Label(self, text="Attachments:", padding=(10, 4, 10, 0)).pack(anchor="w")
|
||||
self.attach_frame = ttk.Frame(self, padding=(10, 0))
|
||||
self.attach_frame.pack(fill="x")
|
||||
self._build_attachments()
|
||||
ttk.Button(self, text="Add another file...", command=self._add_attachment).pack(anchor="w", padx=10)
|
||||
|
||||
action_row = ttk.Frame(self, padding=10)
|
||||
action_row.pack(fill="x")
|
||||
ttk.Button(action_row, text="Generate Drafts", command=self.generate_drafts).pack(side="left")
|
||||
ttk.Button(action_row, text="Open Drafts Folder", command=self.open_output_folder).pack(side="left", padx=6)
|
||||
|
||||
self.status = tk.Text(self, height=8, wrap="word")
|
||||
self.status.pack(fill="both", padx=10, pady=(0, 10))
|
||||
self.status.configure(state="disabled")
|
||||
|
||||
def _build_attachments(self):
|
||||
for widget in self.attach_frame.winfo_children():
|
||||
widget.destroy()
|
||||
for path in DEFAULT_ATTACHMENTS:
|
||||
var = tk.BooleanVar(value=path.exists())
|
||||
label = path.name + ("" if path.exists() else " (not found)")
|
||||
cb = ttk.Checkbutton(self.attach_frame, text=label, variable=var)
|
||||
cb.pack(anchor="w")
|
||||
self.attachment_vars.append((var, path))
|
||||
|
||||
def _add_attachment(self):
|
||||
picked = filedialog.askopenfilename(title="Choose a file to attach")
|
||||
if picked:
|
||||
path = Path(picked)
|
||||
var = tk.BooleanVar(value=True)
|
||||
ttk.Checkbutton(self.attach_frame, text=path.name, variable=var).pack(anchor="w")
|
||||
self.attachment_vars.append((var, path))
|
||||
|
||||
def _set_all(self, value):
|
||||
for var, _ in self.contact_vars:
|
||||
var.set(value)
|
||||
|
||||
def refresh_contacts(self):
|
||||
for widget in self.contacts_frame.winfo_children():
|
||||
widget.destroy()
|
||||
self.contact_vars = []
|
||||
|
||||
if not mm.CONTACTS_FILE.exists():
|
||||
ttk.Label(self.contacts_frame, text="Contact sheet not found.").pack(anchor="w")
|
||||
return
|
||||
|
||||
try:
|
||||
df = pd.read_excel(mm.CONTACTS_FILE, sheet_name="Contacts")
|
||||
except Exception as exc:
|
||||
ttk.Label(self.contacts_frame, text=f"Couldn't read the sheet: {exc}").pack(anchor="w")
|
||||
return
|
||||
|
||||
for _, row in df.iterrows():
|
||||
sent = str(row.get("Materials Sent", "")).strip().lower() == "yes"
|
||||
conference = str(row.get("Conference", "")).strip()
|
||||
conference = "" if conference.lower() == "nan" else conference
|
||||
label = f"{row.get('First Name', '')} {row.get('Last Name', '')} <{row.get('Email', '')}>"
|
||||
if conference:
|
||||
label += f" — {conference}"
|
||||
if sent:
|
||||
label += " (already marked as sent)"
|
||||
|
||||
var = tk.BooleanVar(value=False)
|
||||
cb = ttk.Checkbutton(self.contacts_frame, text=label, variable=var)
|
||||
cb.pack(anchor="w")
|
||||
if sent:
|
||||
cb.state(["disabled"])
|
||||
self.contact_vars.append((var, row.to_dict()))
|
||||
|
||||
if not self.contact_vars:
|
||||
ttk.Label(self.contacts_frame, text="No contacts in the sheet yet.").pack(anchor="w")
|
||||
|
||||
def _log(self, message):
|
||||
self.status.configure(state="normal")
|
||||
self.status.insert("end", message + "\n")
|
||||
self.status.see("end")
|
||||
self.status.configure(state="disabled")
|
||||
|
||||
def generate_drafts(self):
|
||||
selected_rows = [row for var, row in self.contact_vars if var.get()]
|
||||
if not selected_rows:
|
||||
messagebox.showinfo("Nothing selected", "Check at least one contact first.")
|
||||
return
|
||||
|
||||
attach_paths = [str(path) for var, path in self.attachment_vars if var.get() and path.exists()]
|
||||
from_address = self.from_var.get().strip() or mm.DEFAULT_FROM
|
||||
|
||||
self.status.configure(state="normal")
|
||||
self.status.delete("1.0", "end")
|
||||
self.status.configure(state="disabled")
|
||||
|
||||
try:
|
||||
full_df = pd.read_excel(mm.CONTACTS_FILE, sheet_name="Contacts")
|
||||
except Exception as exc:
|
||||
messagebox.showerror("Can't read sheet", f"Close the Excel file and try again.\n\n{exc}")
|
||||
return
|
||||
if "Draft File" not in full_df.columns:
|
||||
full_df["Draft File"] = ""
|
||||
|
||||
try:
|
||||
subject_template, body_template = mm.load_template()
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Template problem", str(exc))
|
||||
return
|
||||
wrote_any = False
|
||||
|
||||
for row in selected_rows:
|
||||
subject = mm.fill(subject_template, row)
|
||||
body = mm.fill(body_template, row)
|
||||
msg = mm.build_message(subject, body, row, attach_paths, from_address)
|
||||
|
||||
if "[[MISSING:" in subject or "[[MISSING:" in body:
|
||||
self._log(f"WARNING: missing field(s) for {row['Email']} -- check before sending")
|
||||
|
||||
out_path = mm.write_draft(msg, row)
|
||||
wrote_any = True
|
||||
self._log(f"Wrote draft: {out_path}")
|
||||
|
||||
match = full_df["Email"].astype(str).str.strip().str.lower() == str(row["Email"]).strip().lower()
|
||||
full_df.loc[match, "Draft File"] = str(out_path)
|
||||
|
||||
if wrote_any:
|
||||
try:
|
||||
full_df.to_excel(mm.CONTACTS_FILE, index=False, sheet_name="Contacts")
|
||||
mm.add_draft_links(mm.CONTACTS_FILE)
|
||||
except Exception as exc:
|
||||
self._log(f"Couldn't update the Excel sheet's Draft File links: {exc}")
|
||||
|
||||
self._log("\nDone. Click 'Open Drafts Folder' to review and send.")
|
||||
self.refresh_contacts()
|
||||
|
||||
def open_output_folder(self):
|
||||
mm.OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
subprocess.run(["open", str(mm.OUTPUT_DIR)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = MailMergeApp()
|
||||
app.mainloop()
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mail merge for conference contacts.
|
||||
|
||||
Reads conference_contacts.xlsx + email_template.txt, fills in the
|
||||
{{Placeholder}} fields per row, and writes one .eml draft per contact into
|
||||
output_drafts/. Double-click a .eml file to open it as a draft in your
|
||||
default mail app (Mail.app, Outlook, etc.) so you can read it over and hit
|
||||
send yourself -- nothing is sent automatically by default.
|
||||
|
||||
Signatures are not handled here -- Mailcow/SOGo applies whatever signature
|
||||
is configured on the sending mailbox.
|
||||
|
||||
Usage:
|
||||
python3 mail_merge.py
|
||||
python3 mail_merge.py --attach ~/Documents/TXproducts_Materials/txservices.pdf ~/Documents/TXproducts_Materials/wavegate.pdf
|
||||
python3 mail_merge.py --send # actually send via SMTP (see below)
|
||||
|
||||
Sending directly requires these environment variables:
|
||||
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS
|
||||
and asks for a y/n confirmation before every single email.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.styles import Font
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
CONTACTS_FILE = HERE / "conference_contacts.xlsx"
|
||||
TEMPLATE_FILE = HERE / "email_template.txt"
|
||||
OUTPUT_DIR = HERE / "output_drafts"
|
||||
|
||||
# Default sender for .eml drafts, so Mail.app picks the right account when
|
||||
# more than one is configured. --send overrides this with SMTP_USER instead.
|
||||
DEFAULT_FROM = "mckinley@mckinleyblack.com"
|
||||
|
||||
PLACEHOLDER_RE = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
|
||||
|
||||
|
||||
def load_template(path=None):
|
||||
path = path or TEMPLATE_FILE
|
||||
text = path.read_text(encoding="utf-8")
|
||||
first_line, _, body = text.partition("\n")
|
||||
if not first_line.upper().startswith("SUBJECT:"):
|
||||
raise ValueError(f"{path} must start with a 'SUBJECT: ...' line")
|
||||
subject_template = first_line[len("SUBJECT:"):].strip()
|
||||
return subject_template, body.strip()
|
||||
|
||||
|
||||
def fill(template, row):
|
||||
def replace(match):
|
||||
column = match.group(1)
|
||||
if column not in row or pd.isna(row[column]) or str(row[column]).strip() == "":
|
||||
return f"[[MISSING: {column}]]"
|
||||
return str(row[column]).strip()
|
||||
|
||||
return PLACEHOLDER_RE.sub(replace, template)
|
||||
|
||||
|
||||
def build_message(subject, body, row, attach_paths, from_address):
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = from_address
|
||||
msg["To"] = row["Email"]
|
||||
msg.set_content(body)
|
||||
|
||||
for attach_path in attach_paths or []:
|
||||
attach_path = Path(attach_path)
|
||||
data = attach_path.read_bytes()
|
||||
ctype, _ = mimetypes.guess_type(str(attach_path))
|
||||
maintype, subtype = (ctype or "application/octet-stream").split("/", 1)
|
||||
msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=attach_path.name)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def _applescript_escape(text):
|
||||
return text.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def open_in_mail_app(subject, body, to_address, attach_paths=None, from_address=None):
|
||||
"""Opens a genuine new Mail.app compose window (not a .eml import), so Mail.app
|
||||
treats it as a real draft -- your signature gets applied automatically, and it's
|
||||
fully editable. Nothing is sent; the window is left open (visible) for review."""
|
||||
lines = ["tell application \"Mail\"", "activate"]
|
||||
lines.append(
|
||||
"set newMessage to make new outgoing message with properties "
|
||||
f'{{subject:"{_applescript_escape(subject)}", '
|
||||
f'content:"{_applescript_escape(body)}" & return & return, visible:true}}'
|
||||
)
|
||||
lines.append("tell newMessage")
|
||||
lines.append(
|
||||
f'make new to recipient at end of to recipients with properties {{address:"{_applescript_escape(to_address)}"}}'
|
||||
)
|
||||
if from_address:
|
||||
lines.append("try")
|
||||
lines.append(f'set sender to "{_applescript_escape(from_address)}"')
|
||||
lines.append("end try")
|
||||
for attach_path in attach_paths or []:
|
||||
posix_path = str(Path(attach_path).expanduser())
|
||||
lines.append(
|
||||
f'make new attachment with properties {{file name:(POSIX file "{_applescript_escape(posix_path)}" as alias)}} at after last paragraph of content'
|
||||
)
|
||||
lines.append("end tell")
|
||||
lines.append("end tell")
|
||||
script = "\n".join(lines)
|
||||
|
||||
result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Mail.app automation failed: {result.stderr.strip()}")
|
||||
|
||||
|
||||
DASH_FILE = HERE.parent.parent / "index.html"
|
||||
|
||||
|
||||
def log_to_dash_outreach(email, note):
|
||||
"""Appends a timestamped entry to the matching presenter's Outreach Log in the Sales &
|
||||
Marketing dash (index.html), matched by email. Silently does nothing if the dash file
|
||||
isn't found or no presenter matches -- this is a best-effort bridge between the two
|
||||
separate systems, not a hard requirement for the mail merge itself to work."""
|
||||
import datetime
|
||||
import json
|
||||
import re as _re
|
||||
|
||||
if not DASH_FILE.exists():
|
||||
return False
|
||||
|
||||
html = DASH_FILE.read_text(encoding="utf-8")
|
||||
m = _re.search(r'(<script id="conferences-data" type="application/json">)(.*?)(</script>)', html, re.S)
|
||||
if not m:
|
||||
return False
|
||||
|
||||
data = json.loads(m.group(2))
|
||||
email_lower = email.strip().lower()
|
||||
matched = False
|
||||
for conf in data.get("conferences", []):
|
||||
for p in conf.get("presenters", []):
|
||||
if (p.get("email") or "").strip().lower() == email_lower:
|
||||
p.setdefault("outreach_log", []).append({
|
||||
"date": datetime.datetime.now().strftime("%d.%m.%y"),
|
||||
"note": note,
|
||||
})
|
||||
matched = True
|
||||
|
||||
if not matched:
|
||||
return False
|
||||
|
||||
new_blob = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
html = html[:m.start()] + m.group(1) + new_blob + m.group(3) + html[m.end():]
|
||||
DASH_FILE.write_text(html, encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def write_draft(msg, row):
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
conference = str(row.get("Conference", "")).strip()
|
||||
label = f"{row['First Name']}_{row['Last Name']}"
|
||||
if conference and conference.lower() != "nan":
|
||||
label += f"_{conference[:40]}"
|
||||
safe_name = re.sub(r"[^\w.-]", "_", label)
|
||||
|
||||
out_path = OUTPUT_DIR / f"{safe_name}.eml"
|
||||
n = 2
|
||||
while out_path.exists():
|
||||
out_path = OUTPUT_DIR / f"{safe_name}_{n}.eml"
|
||||
n += 1
|
||||
|
||||
out_path.write_bytes(bytes(msg))
|
||||
return out_path
|
||||
|
||||
|
||||
def add_draft_links(path, column_name="Draft File"):
|
||||
wb = load_workbook(path)
|
||||
ws = wb["Contacts"]
|
||||
headers = {cell.value: cell.column for cell in ws[1]}
|
||||
col = headers.get(column_name)
|
||||
if not col:
|
||||
return
|
||||
for (cell,) in ws.iter_rows(min_row=2, min_col=col, max_col=col):
|
||||
if cell.value:
|
||||
cell.hyperlink = "file://" + urllib.parse.quote(str(cell.value))
|
||||
cell.font = Font(color="0563C1", underline="single")
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def send_message(msg):
|
||||
host = os.environ["SMTP_HOST"]
|
||||
port = int(os.environ.get("SMTP_PORT", 587))
|
||||
user = os.environ["SMTP_USER"]
|
||||
password = os.environ["SMTP_PASS"]
|
||||
del msg["From"]
|
||||
msg["From"] = user
|
||||
|
||||
if port == 465:
|
||||
# Implicit TLS from the first byte -- SMTP_SSL, not STARTTLS.
|
||||
with smtplib.SMTP_SSL(host, port) as server:
|
||||
server.login(user, password)
|
||||
server.send_message(msg)
|
||||
else:
|
||||
# e.g. 587 -- plaintext connection upgraded via STARTTLS.
|
||||
with smtplib.SMTP(host, port) as server:
|
||||
server.starttls()
|
||||
server.login(user, password)
|
||||
server.send_message(msg)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--attach", nargs="+", help="one or more file paths to attach (e.g. txservices.pdf wavegate.pdf)")
|
||||
parser.add_argument("--from-address", dest="from_address", default=DEFAULT_FROM, help="sender address for .eml drafts (--send uses SMTP_USER instead)")
|
||||
parser.add_argument("--send", action="store_true", help="send via SMTP instead of writing .eml drafts")
|
||||
parser.add_argument("--only", nargs="+", help="only process these email addresses, skip everyone else in the sheet")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not CONTACTS_FILE.exists():
|
||||
sys.exit(f"Contact sheet not found: {CONTACTS_FILE}")
|
||||
|
||||
full_df = pd.read_excel(CONTACTS_FILE, sheet_name="Contacts")
|
||||
if "Draft File" not in full_df.columns:
|
||||
full_df["Draft File"] = ""
|
||||
try:
|
||||
subject_template, body_template = load_template()
|
||||
except ValueError as exc:
|
||||
sys.exit(str(exc))
|
||||
|
||||
df = full_df
|
||||
if args.only:
|
||||
wanted = {addr.strip().lower() for addr in args.only}
|
||||
df = full_df[full_df["Email"].str.strip().str.lower().isin(wanted)]
|
||||
if df.empty:
|
||||
sys.exit("None of the --only addresses matched a row in the contact sheet.")
|
||||
|
||||
drafts_written = False
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
if str(row.get("Materials Sent", "")).strip().lower() == "yes":
|
||||
print(f"Skipping {row['First Name']} {row['Last Name']} (already marked as sent)")
|
||||
continue
|
||||
|
||||
subject = fill(subject_template, row)
|
||||
body = fill(body_template, row)
|
||||
msg = build_message(subject, body, row, args.attach, args.from_address)
|
||||
|
||||
if "[[MISSING:" in subject or "[[MISSING:" in body:
|
||||
print(f"WARNING: missing field(s) for {row['Email']} -- check before sending")
|
||||
|
||||
if args.send:
|
||||
preview = f"To: {row['Email']}\nSubject: {subject}\n\n{body}\n"
|
||||
print("-" * 60)
|
||||
print(preview)
|
||||
answer = input(f"Send this to {row['Email']}? [y/N] ").strip().lower()
|
||||
if answer == "y":
|
||||
send_message(msg)
|
||||
print(f"Sent to {row['Email']}")
|
||||
time.sleep(2)
|
||||
else:
|
||||
print(f"Skipped {row['Email']}")
|
||||
else:
|
||||
out_path = write_draft(msg, row)
|
||||
full_df.at[idx, "Draft File"] = str(out_path)
|
||||
drafts_written = True
|
||||
print(f"Wrote draft: {out_path}")
|
||||
|
||||
if not args.send:
|
||||
if drafts_written:
|
||||
full_df.to_excel(CONTACTS_FILE, index=False, sheet_name="Contacts")
|
||||
add_draft_links(CONTACTS_FILE)
|
||||
print(f"\nDone. Open the .eml files in {OUTPUT_DIR} to review and send.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
SUBJECT: {{Conference}} — information as promised, {{First Name}}
|
||||
|
||||
Hi {{First Name}}, as promised, attached.
|
||||
|
||||
Best regards,
|
||||
@@ -0,0 +1,8 @@
|
||||
SUBJECT: Great meeting you at {{Conference}}, {{First Name}}!
|
||||
|
||||
Hi {{First Name}}, we met on {{Date Met}} at the {{Conference}}. Attached
|
||||
here is the information material I promised.
|
||||
|
||||
Looking forward to hearing from you.
|
||||
|
||||
Best regards,
|
||||
@@ -0,0 +1,14 @@
|
||||
SUBJECT: Following up on {{Topic}}
|
||||
|
||||
Hi {{First Name}},
|
||||
|
||||
It was great meeting you at {{Conference}}. Following up on our conversation
|
||||
about {{Topic}} on {{Date Met}}.
|
||||
|
||||
Attached is more detail on how it speeds up beamline experiments.
|
||||
|
||||
Happy to set up a short call if useful.
|
||||
|
||||
Best regards,
|
||||
|
||||
Mckinley
|
||||
@@ -0,0 +1,430 @@
|
||||
<title>Mail Merge Rollout</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Futura Doc";
|
||||
font-weight: 500;
|
||||
src: url(data:font/woff2;base64,d09GMgABAAAAAGU0ABEAAAABMLgAAGTHAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG8xyHBAGYACGNC0cP2ZvbmTXLAqBr3yBjF0BNgIkA400E+d8C4ZcAAQgP21ldGGmcjW7EAWRHAeTFVuXGrGFoJP9/fSlAQCTxFrpJgMuflM1X1m1DyjbviSD9GZdy/wOMrMCdmwZ2DhgMNJjKP///////////79maYRs5vJ/JN9vaUsFFQCnTubmgoiCoqJtWEE2c4NJGzS06Cg0M4t20VHFg4on0f0MT0tvWA9s24olKlGJKY5RQQUb1qngUKwwErFpNCbWSRMUGp+eTI4wPam6eN/2UjQVnuEXoaERBU3RwURoZn6tsKAxcSlNf1pNHBU0o8FFkoKCFFXlvAMuVyhut+BofixWzXv+t10wKvlb/l/533FVnddE9334cOrP/Pf8H7sfv/GV/JFmrvgH38q+PZOXA1XCdmP+pkp82t1nDORiOnCvpc5wSfw5M+MMhbFar9vZNfxNfsv7D71UdIHy/lApy4N/8SHhjP84Bp+o0OD4A3PR9wem2kuni9l6GdhS/SHAdj2MGFM0w5JLnjw80b1955a6E38zmcyXl1YWzgLwjBdO3qshWOeZ+mHldUz245Mv9Z5Xz/P0M1GxmX4/MRM9sY3ptdpaxSYWlo3DqanEl7PsaJzkMd+vKGMM1DEhL0JF5bCwuKjCBXFCepT3p9zyZwIBdt4MWfpNnO1XH8HWA0FqkvAu0N7s+EXlVBGNrJzqPN9vfp4VQnKTaR8Tyhqs6a+/w7O3Z4SWHarO9/AIqg2XGmcL5DMMs0NUR2Hu99nKXpoVMqXtiVmFE5WpOyeTDRUj1KdORac+a5MJaQg1jlAhGzIhhBDqhBBWjBPWbPZ/QvNEKprflW3pRJtqRCpGRVMHqkrNqBAoc5YSMVKi7PHs5xMB8qKJt7/IxN7J/P5JtgUABW77G6OCKRmqJRibw6NtvhgHnvNE80IFv8ek1xEBbg6/ZQBhPmTCCWGy+gft3gc0ArqxylAixZihVorRzJCxyFBkKPQ0Zij9eeP/PkTdnN8fGMiCLRUv6yyTJyC36prEWV+YmW+Nn1RNknNlMMbbh6Gqw6Akvr4+KjKC62WadJ8ojgeXTaFk8wtcGWyDjfcDo8jgnHAIyIt00o3szA4Xhw04MBtYrjofHz5+4HdZGprZx1RqXJimG/v4D4qlkYiYMui28+AYkaeAkiMEqS9aqsb/zlWDLb9R56uWUQ0Aumzzp00h2RcYcuL8WXU8fL/Wm51Tr16lJ2EhW3T5FvKTMHHqTwe6br2wmz8B3QFUHeJFdA5o1iH6VevlKuP3/2xapVXWaKa17NkD7bIOIcgQMqAg47DqV1eXuqtbbrXAsmTvyrJnT/LM7BMYNQduy5bhSOMjexYhOgTLg/aiZw+Bsov2osswuyAa6d6lOVMUXJRSlFySMPy/LX3h99HTSn+d0sUNYAAtrDM8K+dpc+emWwG4Ah7/SRkXsDBWLktqVX+k0LF4RlzyfdfWfOAOiP/srOIFaACoAaGjQwdPuZdmL3nJlb0GZG5CqQpjg3DfySt9N4reXOvCBBf5JYPCd9stwkGVbKjJCoVw7vKVU7CU0yUFGpi+Hv/Pd1Hq9j67YgHBP5RTZdMS0PQCH0jwSPicb9k306573MKYIBYRRDD9qv/n/9dn7czrHVeUGFG2Fi1alChRthZla1HiiHK0w2QevwPx+M7tEuibGVqseLHOg4gHlX9zq3S7100Q24hI0dE0DENtc2CwKkSGDSiVCpxxcFYQ9l6fioqGkzuYytPPjZ9eCeYhsrBglG6rx6llM+CKAadP0fp3ZOniC/xggVnHUsd25asSyQCiwDDcdq+7RIYBCKezpiR3YRSnXPLC/2V8U5AjMfpiMf6nQxMs4VN4Sk1n00R6kFbTm/QzH5zlsnYeKaCsFVpZrJB6ZGXU1YZuYe3uYDr4jvwjdlyc8Z0pnpWYVZqFzeJnybNhs0Ozt2cfzPHMHTynNAef85wLnIucPz3vOe87P7lwegG50Lwwu0he3FwKXPq6/MtwnTrL/DZfPDXlT+3KfrDOlmEz7YZ9s/0s/8/5pf0OPQ5Vh+KhgdBcaDO0E9oHHYSOQW9Dn0D/CngIvBUUELQQDBSsF5wX4hHSF7IQeiCsKUwTLhSuEJ4Qnhb+KiIhMisyL8ojWiFaL3pNTE5MXaxRXF38gvgTCVOJZolHkmjJRMk+KU9pAWlVaX1pW+lA6UzpWukRmXCZWJlkmSuyMFmSLEM2V7ZEdlvOXs5N7q7cpjxEXkm+VoGkkK9QrHBXYVYRAo4afqAtwCEIe0h7KKsCLRBaILZAakITKA0pg0bfZLPluEIXJY8aXoBAtBAtRktNyOjPZ9phOnxo4kRZ0VXdrlpete6GUpKnKeGd4C21PGbB4/HGdi11UUNsTUVHK5apXnlo4lcsFUvFssNLLnc+rXb+PXNm8xa63AgqjZScbNPU35xuxkx/zo6Y83y+Hy9u/XyxBUtNSTFOezFP/chJFXle0rwl+qiUCvT9yAfY/lnwGr1AY/n1pmlKF9B+Heuj1HEN8fXBAogNjIW4sp28n4KFiwEvq738eURJsq1UK+nQaxq6um9C7HJbu3Rz/zxPjrebfNZdQThZTrlPT33EX+Jf+P9RbdBCrE7iOCbLUUmBEkEJEYiDVCD346/q/a69lKIslY+qh4IcmHYcbazuAIhAbEuGyHWXvk4TtHNHGXgmUY7U4jonP/xNBCwoRCB+HQ2oY7AtSOvQUkfvY0wNunaG2gRgbrNFXP+5a3+pgKAQgRjyXkQ8kkhHhsgzFLpcXPGHRSP0XVybJ/JI2lSWPyUgKEQgDlIScBnmHZO5XOsAU9quYr8Mpo++Kod7ik5lTgc2xe2BLyu/R7xGMiJD5ImPzbhQ0+8qSt21/dQdbNAYNgn13PZV7PN9sLQrSnjHtDlEXC+XsW4AvoqA5wSIg0QrvCfH/W74CPArRcnKKl+3D+QYYgyq0bZm+1pha0zrwOjGKtwMAY0EE9sSQFgzuY7oEJdsp3XLaJaPj/o7C+oVaZQ0q4cm4RlEKtnl62wqIZAkQLxVqqwTVEJuGymOxh8F/YdrmSw2/RKPx8/XRZqIh2Q7nZ1Jz7cLOcUu/9mnAdfb+fuwMV6kLSSMHKhUWsuZArbb7mr4ULiE7LjfRGRFXJKR7mUiT/2umo9RKPXv/D9RUnf9SeuURr6dp2p3GdOqVeu/t2kaGA/lw3ohacuFK3lownbGftZROpZ9FSLokHQoFnRZXWVMggnEDMJWxt3DmcOTwhv4C3fxfvmbCAoRiF0Ly+Xsr7mF0sXkX3n4nVlaUfak0s3TFCD8qMs2reyaORSc6kCXg0IEYtejnzsGrARqHVw30oLljJUUNG5bB3WfqFXut6ZDt5Vpu0kHtg1cGXcdTUpwOBwOh1NCZeMcnKulUFbpLpUTOs2TI7+AfbDvh2Teal2uUCeBxWKxWKwEhYW1j0KhLFfqwQ5t52J5F34/XaCk4IIk55U3S5pvk8skjDMkEocFm9l6qaPtNedJNQuDwWAwGAtIdCWSJBLp50g1PKX4DiuoaZj18gB5hY/abuF7a4R31TZaZbFKeamGC0jX1vhy+fRfAqRGoQ1hDNE7ZjLNbJLVCFYvlwsRQ+KGJA1JG5LRK69Xoc6ibn90q6FWgy+ogYOY9WoB2QOXRwz1/u1R9vz6j5QK/XftQ/W2AI4rV9Wcqi7r0NU+HO/cmYlqWneddml0e3B92WdHnNuYHdxc6LQ+ut9+ltomqDNwIdK+ZUNWAJvxkq7oZkN2ksLYhCmYWVjZ2LvNGQ7F7cJ1ArmXe7zkxfCZxqO9eei2p+jzsS0aKbpNGg9x9HGjyh7PbdZt7V+nidqFZadWTKOBGF0wmsRuDCAQuA0s1foc6bn21Dx+Uj+tkz+/zaywbhdsQGtK+9GOxo51tTYLvfrq622ojIyZplLzJucc7SN0YzkHAEnu+efquEofVutosE5dARlLSfELWl960mvCWpTxUPCf5U3iifr9NheqzudQ83leT88/3XC1Ks+3yvPeQH+e8P6wZ52p/lZvzHOSTV7FL9A6PMcwWcXq3J89fL6ayLO2Sje0571Q/c+rhnm5WketOA6ayLvew+92ldelam0YMYZVj1bpPX5vsl6I+zkJB0uTeL5O6PhupDVRHMcNuLY1iXYaSX71V/cG2VObzpO4Ae9qw2zrJjrMTXjxGs/jK8ZwDzo8JuWzpBkL9mQNNvaluc91ZV2qf0/Ekt3c//koPpYGmYlxfkhV2iAT6VzjzUsz/tCrP2/SwzBpCZP4T6Bqf+Ilj1jDb3CYNS85CBSnUyjjso26o86g65hLmONnCU4ICoYIazjiCBxJceNBmScvqrz5UOcrhKZQkQyVKwdTrY65iy5CadYPbdANJHfc4eGhh6jmzPG0bIWXLa94++yLAL/9EeTAgZA4ElXRGAIECtegVYQOnaL1GBVjwqxES8hS0IhlsONUIRdulfLgUQUvXlXyFRndqcJ2AxQpUXqhUp4V33Fq5ZtPvYC3Ny2YX6ohQcac5lrSfS4p53XgbgLXvTIZBZ73zdReNnkvfrRLAsczts1vTQDIv2zCQtpjrh+980peBdwZHKeTATMWHENGqvuBi9QoS+JpLE2lmbSROfKY5/NqNs4VuSPfPH5B5mBXu855N3MH93JfXPfoxNHM9+Wa3JLcgT7zte/82v/wNO83nf19+C/1QzCO7uplNXgQxIBIGk2T93k5g9zybD71mg9ypWudu1FPfMCayuOL4x0rLPYf/X/f6X/6MkAvApNrA5NPA9MHT6OmkcD0GpOCScyEZ9P75atPzz8+//D87fOt5yvPn7zwex5+XrGpbz7YHAFENMjxS3Jqc+vmnE3jWoQSkAfXVWzShTcNeyDeL8+HGbd/fFwkXkoF/jTyWxs2PLine truncated
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Futura Doc";
|
||||
font-weight: 700;
|
||||
src: url(data:font/woff2;base64,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 truncated
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Charter Doc";
|
||||
font-weight: 400;
|
||||
src: url(data:font/woff2;base64,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 truncated
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Charter Doc";
|
||||
font-weight: 700;
|
||||
src: url(data:font/woff2;base64,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 truncated
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Menlo Doc";
|
||||
font-weight: 400;
|
||||
src: url(data:font/woff2;base64,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 truncated
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #eceff0;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f4f6f6;
|
||||
--ink: #14191b;
|
||||
--muted: #5b6a6e;
|
||||
--line: #d3d9da;
|
||||
--accent: #0f5a60;
|
||||
--accent-strong: #0b4448;
|
||||
--accent-soft: #e3f0ef;
|
||||
--amber: #93590c;
|
||||
--amber-soft: #f7ecd9;
|
||||
--done: #2f7a4f;
|
||||
--done-soft: #e4f2e8;
|
||||
--shadow: 0 1px 2px rgba(20, 25, 27, 0.06);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #12171a;
|
||||
--surface: #1a2124;
|
||||
--surface-2: #202a2d;
|
||||
--ink: #e8ebea;
|
||||
--muted: #94a5a8;
|
||||
--line: #2b3538;
|
||||
--accent: #45c9cb;
|
||||
--accent-strong: #7fdcdd;
|
||||
--accent-soft: #1c3132;
|
||||
--amber: #e0a53d;
|
||||
--amber-soft: #2e2618;
|
||||
--done: #63c48a;
|
||||
--done-soft: #1c2f22;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #12171a;
|
||||
--surface: #1a2124;
|
||||
--surface-2: #202a2d;
|
||||
--ink: #e8ebea;
|
||||
--muted: #94a5a8;
|
||||
--line: #2b3538;
|
||||
--accent: #45c9cb;
|
||||
--accent-strong: #7fdcdd;
|
||||
--accent-soft: #1c3132;
|
||||
--amber: #e0a53d;
|
||||
--amber-soft: #2e2618;
|
||||
--done: #63c48a;
|
||||
--done-soft: #1c2f22;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: "Charter Doc", Georgia, "Iowan Old Style", serif;
|
||||
font-size: 17px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 56px 24px 96px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 44px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: "Futura Doc", "Century Gothic", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: "Futura Doc", "Century Gothic", sans-serif;
|
||||
font-weight: 500;
|
||||
text-wrap: balance;
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.masthead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding-bottom: 32px;
|
||||
border-bottom: 2px solid var(--line);
|
||||
}
|
||||
|
||||
.masthead h1 {
|
||||
font-size: clamp(32px, 5vw, 42px);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.masthead .subtitle {
|
||||
font-size: 19px;
|
||||
color: var(--muted);
|
||||
max-width: 56ch;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 28px;
|
||||
margin-top: 6px;
|
||||
font-family: "Menlo Doc", ui-monospace, monospace;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.meta-row span b {
|
||||
color: var(--ink);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
section { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
section > h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.section-lede {
|
||||
color: var(--muted);
|
||||
font-size: 15.5px;
|
||||
max-width: 62ch;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.callout {
|
||||
background: var(--accent-soft);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
padding: 18px 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.callout .eyebrow { color: var(--accent-strong); }
|
||||
|
||||
.callout p { margin: 0; font-size: 15.5px; }
|
||||
|
||||
.checklist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.checklist li {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr;
|
||||
gap: 12px;
|
||||
padding: 13px 4px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.checklist li:last-child { border-bottom: none; }
|
||||
|
||||
.mark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checklist.done .mark {
|
||||
background: var(--done-soft);
|
||||
color: var(--done);
|
||||
border: 1px solid var(--done);
|
||||
}
|
||||
|
||||
.checklist.todo .mark {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--muted);
|
||||
}
|
||||
|
||||
.item-title { font-weight: 700; font-family: "Charter Doc", serif; }
|
||||
|
||||
.item-detail { color: var(--muted); font-size: 14.5px; margin-top: 3px; }
|
||||
|
||||
code {
|
||||
font-family: "Menlo Doc", ui-monospace, monospace;
|
||||
font-size: 0.86em;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.credential-note {
|
||||
background: var(--amber-soft);
|
||||
border-left: 3px solid var(--amber);
|
||||
border-radius: 3px;
|
||||
padding: 16px 22px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.credential-note b { color: var(--ink); }
|
||||
|
||||
.email-preview {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-preview-header {
|
||||
font-family: "Menlo Doc", ui-monospace, monospace;
|
||||
font-size: 13.5px;
|
||||
background: var(--surface-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.email-preview-header .k { color: var(--muted); margin-right: 8px; }
|
||||
.email-preview-body { padding: 20px 22px; font-size: 15.5px; white-space: pre-wrap; }
|
||||
.email-preview-attachments {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 12px 22px;
|
||||
font-family: "Futura Doc", "Century Gothic", sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.email-preview-attachments code { margin-right: 8px; }
|
||||
|
||||
.template-source-label {
|
||||
font-family: "Futura Doc", "Century Gothic", sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 18px 0 8px;
|
||||
}
|
||||
.template-source {
|
||||
font-family: "Menlo Doc", ui-monospace, monospace;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 18px 20px;
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
}
|
||||
.template-source .ph { color: var(--accent-strong); }
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.timeline-row {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr;
|
||||
gap: 18px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.timeline-row:last-child { border-bottom: none; }
|
||||
|
||||
.timeline-day {
|
||||
font-family: "Futura Doc", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent-strong);
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.timeline-row p { margin: 0; font-size: 15.5px; }
|
||||
|
||||
.plain-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 15.5px;
|
||||
}
|
||||
|
||||
.plain-list li::marker {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 2px solid var(--line);
|
||||
padding-top: 24px;
|
||||
font-family: "Menlo Doc", ui-monospace, monospace;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
a { color: var(--accent-strong); }
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.timeline-row { grid-template-columns: 1fr; gap: 4px; }
|
||||
.timeline-day { padding-top: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<header class="masthead">
|
||||
<h1>Mail Merge Rollout</h1>
|
||||
<div class="meta-row">
|
||||
<span>Owner: <b>Mckinley Black</b></span>
|
||||
<span>Target: <b>live by Friday</b></span>
|
||||
<span>Prototype: <code>address_book/conference_mail_merge/</code></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>What’s already built</h2>
|
||||
<ul class="checklist done">
|
||||
<li><span class="mark">✓</span><div><div class="item-title">TEST Contact sheet</div><div class="item-detail">SPIE 2026 Presenter Contacts — all 137 conference presenters across all four SPIE Optics + Photonics sessions, one sheet per conference, downloadable straight from the Sales & Marketing dash.</div></div></li>
|
||||
<li><span class="mark">✓</span><div><div class="item-title">TEST Personalised template</div><div class="item-detail">Subject and body filled in per contact from the sheet.</div></div></li>
|
||||
<li><span class="mark">✓</span><div><div class="item-title">TEST Draft generation</div><div class="item-detail">One reviewable draft per contact. Nothing sends without a human opening it and hitting send.</div></div></li>
|
||||
<li><span class="mark">✓</span><div><div class="item-title">TEST Direct-send path</div><div class="item-detail">Optional. Shows a preview and asks yes/no before every single email goes out.</div></div></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What the mail actually looks like</h2>
|
||||
<p class="section-lede">A real draft generated from <code>conference_contacts.xlsx</code>, using the <code>wavegate_focus</code> template — this is exactly what sits in <code>output_drafts/</code> today, unedited.</p>
|
||||
<div class="email-preview">
|
||||
<div class="email-preview-header">
|
||||
<div><span class="k">Subject:</span>Following up on Radiation-Hardness Testing & Wavegate</div>
|
||||
<div><span class="k">From:</span>mckinley@mckinleyblack.com</div>
|
||||
<div><span class="k">To:</span>pgaal@txproducts.de</div>
|
||||
</div>
|
||||
<div class="email-preview-body">Hi Peter,
|
||||
|
||||
It was great meeting you at SPIE 2026. Following up on our conversation
|
||||
about Radiation-Hardness Testing & Wavegate on 25.08.26.
|
||||
|
||||
Attached is more detail on how it speeds up beamline experiments.
|
||||
|
||||
Happy to set up a short call if useful.
|
||||
|
||||
Best regards,
|
||||
|
||||
Mckinley</div>
|
||||
<div class="email-preview-attachments"><code>txservices.pdf</code><code>wavegate.pdf</code></div>
|
||||
</div>
|
||||
<div class="template-source-label">The template behind it — <code>message_templates/wavegate_focus.txt</code></div>
|
||||
<pre class="template-source">SUBJECT: Following up on <span class="ph">{{Topic}}</span>
|
||||
|
||||
Hi <span class="ph">{{First Name}}</span>,
|
||||
|
||||
It was great meeting you at <span class="ph">{{Conference}}</span>. Following up on our conversation
|
||||
about <span class="ph">{{Topic}}</span> on <span class="ph">{{Date Met}}</span>.
|
||||
|
||||
Attached is more detail on how it speeds up beamline experiments.
|
||||
|
||||
Happy to set up a short call if useful.
|
||||
|
||||
Best regards,
|
||||
|
||||
Mckinley</pre>
|
||||
</section>
|
||||
|
||||
<section class="callout">
|
||||
<div class="eyebrow">Signatures: it’s Mail.app, not Mailcow — won’t apply to <code>--send</code></div>
|
||||
<p>No custom HTML signature is built or embedded in the script — it only ever generates the personalised subject and body. The signature that seems to appear automatically (photo, links, DSGVO footer) turned out to be the local signature configured in the Mac’s own Mail.app, not a Mailcow server-side feature. That only inserts when composing a new message in Mail.app itself, so it won’t apply to anything sent via <code>--send</code>, which talks straight to SMTP. The automated send path currently goes out with no signature at all — open decision: is that acceptable, or does it need solving (script-side embedding again, or a server-side fix on the mail server)?</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What we need from Web Dev this week</h2>
|
||||
<p class="section-lede">The prototype already speaks SMTP. It just needs real credentials and a few checks against mail.txproducts.de before it’s safe to point at actual contacts.</p>
|
||||
<ul class="checklist todo">
|
||||
<li><span class="mark"></span><div><div class="item-title">A dedicated app password</div><div class="item-detail">In Mailcow, under the <code>mblack@txproducts.de</code> mailbox, create an app password scoped to SMTP only — name it something identifiable, e.g. <code>conference-mail-merge</code>. Don’t reuse the account login password.</div></div></li>
|
||||
<li><span class="mark"></span><div><div class="item-title">Confirm host & port</div><div class="item-detail">Expected: <code>mail.txproducts.de</code>, port <code>587</code> (STARTTLS) — Mailcow’s standard submission port. Just needs confirming against the real setup.</div></div></li>
|
||||
<li><span class="mark"></span><div><div class="item-title">DKIM check</div><div class="item-detail">Confirm DKIM signing is active for txproducts.de in Mailcow. Mail without it is more likely to get flagged as spam.</div></div></li>
|
||||
<li><span class="mark"></span><div><div class="item-title">SPF check</div><div class="item-detail">Confirm the domain’s SPF record covers Mailcow’s sending IP. Should already be true — worth a look before any real send.</div></div></li>
|
||||
<li><span class="mark"></span><div><div class="item-title">Rate limit</div><div class="item-detail">Check the mailbox’s send-rate limit comfortably covers a burst of 20–30 emails. The script already waits a couple of seconds between sends either way.</div></div></li>
|
||||
<li><span class="mark"></span><div><div class="item-title">Where it runs</div><div class="item-detail">For now: a laptop, run by hand, whenever there’s a batch to send. Fine for this week — flag if that should change later.</div></div></li>
|
||||
<li><span class="mark"></span><div><div class="item-title">Nextcloud’s role</div><div class="item-detail">Nextcloud Mail is only a client UI on the same Mailcow account — there’s no separate Nextcloud-side setup to do. Just confirm that’s still true for whichever mailbox this ends up using.</div></div></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>TXproducts · Sales & Marketing</span>
|
||||
<span>Questions before Monday → mblack@txproducts.de</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}TXproducts Mail Merge{% endblock %}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body {
|
||||
background: #f7f9fc;
|
||||
font-family: -apple-system, "Source Sans Pro", "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
color: #1b2733;
|
||||
padding: 28px 32px 60px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: #1b3a5c;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.sub { color: #5b6a78; margin: 0 0 24px; font-size: 0.95rem; }
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e0e7ef;
|
||||
border-radius: 10px;
|
||||
padding: 20px 22px;
|
||||
margin-bottom: 20px;
|
||||
max-width: 760px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1.05rem;
|
||||
color: #1b3a5c;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
label { display: block; margin-bottom: 10px; }
|
||||
.contact-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #f0f3f7;
|
||||
}
|
||||
.contact-row:last-child { border-bottom: none; }
|
||||
.contact-row .meta { color: #5b6a78; font-size: 0.85rem; }
|
||||
.contact-row.disabled { color: #b6bfc8; }
|
||||
select, input[type=text] {
|
||||
padding: 7px 9px;
|
||||
border: 1px solid #d3dbe4;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
button {
|
||||
background: #2d6a9f;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 9px 16px;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
margin-right: 8px;
|
||||
}
|
||||
button.secondary { background: #5b6a78; }
|
||||
button:hover { opacity: 0.9; }
|
||||
.warning {
|
||||
background: #fff6e0;
|
||||
border: 1px solid #f0d99a;
|
||||
color: #8a6100;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #eef1f5; vertical-align: top; font-size: 0.9rem; }
|
||||
th { color: #5b6a78; font-weight: 600; }
|
||||
pre { white-space: pre-wrap; font-family: inherit; margin: 0; }
|
||||
a { color: #2d6a9f; }
|
||||
code { background: #f0f3f7; padding: 1px 5px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>TXproducts Mail Merge</h1>
|
||||
<p class="sub">Conference follow-ups — check who gets a message, pick a template, generate or send.</p>
|
||||
|
||||
{% if error %}<div class="warning">{{ error }}</div>{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<div class="card">
|
||||
<h2>Contacts</h2>
|
||||
<div style="margin-bottom:10px;">
|
||||
<button type="button" onclick="document.querySelectorAll('.contact-check').forEach(c=>c.checked=true)">Select All</button>
|
||||
<button type="button" class="secondary" onclick="document.querySelectorAll('.contact-check').forEach(c=>c.checked=false)">Select None</button>
|
||||
<a href="/" style="margin-left:8px; font-size:0.85rem;">Refresh from Excel</a>
|
||||
</div>
|
||||
{% for c in contacts %}
|
||||
<div class="contact-row {% if c.sent %}disabled{% endif %}">
|
||||
<input type="checkbox" class="contact-check" name="contacts" value="{{ c.email }}" {% if c.sent %}disabled{% endif %}>
|
||||
<span>{{ c.first }} {{ c.last }} <{{ c.email }}></span>
|
||||
{% if c.conference %}<span class="meta">— {{ c.conference }}</span>{% endif %}
|
||||
{% if c.draft_opened %}<span class="meta">(draft opened {{ c.draft_opened }})</span>{% endif %}
|
||||
{% if c.sent %}<span class="meta">(already marked as sent)</span>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="sub">No contacts in the sheet yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Message template</h2>
|
||||
<label>Standard message:
|
||||
<select name="template">
|
||||
{% for t in templates %}
|
||||
<option value="{{ t }}">{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<h2 style="margin-top:18px;">Attachments</h2>
|
||||
{% for a in attachments %}
|
||||
<label style="display:inline-flex; align-items:center; gap:6px; margin-right:16px;">
|
||||
<input type="checkbox" name="attachments" value="{{ a.path }}" {% if a.exists %}checked{% else %}disabled{% endif %}>
|
||||
{{ a.name }}{% if not a.exists %} (not found){% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
|
||||
<h2 style="margin-top:18px;">From address</h2>
|
||||
<input type="text" name="from_address" value="{{ default_from }}">
|
||||
</div>
|
||||
|
||||
<button type="submit" formaction="/open_in_mail">Open in Mail.app</button>
|
||||
<button type="submit" formaction="/generate">Generate Drafts</button>
|
||||
<button type="submit" formaction="/preview">Preview & Send Batch</button>
|
||||
<button type="button" class="secondary" onclick="fetch('/open_folder', {method:'POST'})">Open Drafts Folder</button>
|
||||
</form>
|
||||
<script>
|
||||
// Prevent double-clicks/double-submits from firing the same action twice (e.g. writing
|
||||
// two draft files or opening two Mail.app windows for one click).
|
||||
document.querySelector("form").addEventListener("submit", function (e) {
|
||||
if (e.target.dataset.submitted) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.target.dataset.submitted = "true";
|
||||
document.querySelectorAll("form button[type=submit]").forEach(function (btn) {
|
||||
btn.disabled = true;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Preview batch</h1>
|
||||
<p class="sub"><a href="/">← Back</a></p>
|
||||
|
||||
{% if not smtp_ready %}
|
||||
<div class="warning">
|
||||
SMTP isn't configured for this session — set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS as
|
||||
environment variables before starting the web app, then restart it, to enable sending. You can
|
||||
still review below, you just can't send from here yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<tr><th>To</th><th>Subject</th><th>Body</th></tr>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td>{{ r.email }}</td>
|
||||
<td>{{ r.subject }}</td>
|
||||
<td><pre>{{ r.body }}</pre></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if smtp_ready %}
|
||||
<form method="post" action="/send">
|
||||
{% for c in contacts %}<input type="hidden" name="contacts" value="{{ c }}">{% endfor %}
|
||||
<input type="hidden" name="template" value="{{ template }}">
|
||||
{% for a in attachments %}<input type="hidden" name="attachments" value="{{ a }}">{% endfor %}
|
||||
<input type="hidden" name="from_address" value="{{ from_address }}">
|
||||
<button type="submit">Confirm & Send All ({{ rows|length }})</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Drafts generated</h1>
|
||||
<p class="sub"><a href="/">← Back</a></p>
|
||||
|
||||
{% for w in warnings %}<div class="warning">{{ w }}</div>{% endfor %}
|
||||
|
||||
<div class="card">
|
||||
{% for line in lines %}
|
||||
<p><code>{{ line }}</code></p>
|
||||
{% else %}
|
||||
<p>Nothing was generated — no contacts were selected.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button onclick="fetch('/open_folder', {method:'POST'})">Open Drafts Folder</button>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Batch sent</h1>
|
||||
<p class="sub"><a href="/">← Back</a></p>
|
||||
|
||||
<div class="card">
|
||||
{% for line in lines %}
|
||||
<p><code>{{ line }}</code></p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,314 @@
|
||||
#!/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"),
|
||||
)
|
||||
|
||||
|
||||
@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("/quick_open")
|
||||
def quick_open():
|
||||
"""One-click entry point from the Sales & Marketing dash's presenter cards -- opens a
|
||||
real Mail.app draft for a single contact by email, using the default template and
|
||||
attachments. Skips the checkbox/template-picker UI entirely."""
|
||||
email = request.args.get("email", "").strip()
|
||||
template_name = request.args.get("template") or "wavegate_focus.txt"
|
||||
|
||||
if not email:
|
||||
return render_template("results.html", lines=[], warnings=["No email provided."])
|
||||
|
||||
try:
|
||||
full_df = pd.read_excel(mm.CONTACTS_FILE, sheet_name="Contacts")
|
||||
except Exception as exc:
|
||||
return render_template("results.html", lines=[], warnings=[f"Could not read contact sheet: {exc}"])
|
||||
|
||||
if "Draft Opened" not in full_df.columns:
|
||||
full_df["Draft Opened"] = ""
|
||||
|
||||
match_mask = full_df["Email"].astype(str).str.strip().str.lower() == email.lower()
|
||||
if not match_mask.any():
|
||||
return render_template("results.html", lines=[],
|
||||
warnings=[f"No contact with email {email} found in conference_contacts.xlsx yet -- add them there first."])
|
||||
|
||||
try:
|
||||
rows = rows_for_selection([email], template_name)
|
||||
except ValueError as exc:
|
||||
return render_template("results.html", lines=[], warnings=[str(exc)])
|
||||
|
||||
default_attachments = [str(p) for p in DEFAULT_ATTACHMENTS if p.exists()]
|
||||
|
||||
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"], default_attachments, mm.DEFAULT_FROM)
|
||||
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")
|
||||
full_df.loc[match_mask, "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)
|
||||
+389
-259
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user