FactorsAiAutomation/mailer.py
2026-07-01 18:00:54 +05:30

712 lines
25 KiB
Python

"""
mailer.py — Sends a polished HTML digest of new Factors.AI account entries,
with the full export attached as a CSV file.
Design notes
------------
* Card-per-account layout instead of a single wide table. An 11-column table
cannot fit inside an email viewport on any client, and Outlook desktop
(Word rendering engine) cannot horizontally scroll — a wide table there
just renders broken/clipped. Stacked cards render correctly everywhere,
including Outlook, Gmail, Apple Mail and mobile clients, with zero
horizontal overflow.
* All styling is inline (email clients strip most <style>/<head> CSS).
* All values are HTML-escaped before being inserted into markup.
* Layout uses <table role="presentation"> throughout — the only layout
primitive that behaves consistently across Outlook/Gmail/Apple Mail.
* color-scheme / supported-color-schemes are pinned to "light" so clients
don't try to auto dark-mode-invert our intentionally dark navy palette.
"""
from __future__ import annotations
import html
import logging
import smtplib
from datetime import datetime
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid
from pathlib import Path
from typing import Optional, Sequence
import pandas as pd
logger = logging.getLogger(__name__)
# ── Field configuration ─────────────────────────────────────────────────────
# All columns the upstream export may contain.
_ALL_COLS = [
"Company Name",
"Company Industry",
"Company Employee Range",
"Company Annual Revenue",
"Last Activity",
"Company LinkedIn URL",
"Company Description",
"Company Domain",
"Company HQ Address",
"Company HQ City",
"Company Employee Count",
]
_PRIMARY_FIELD = "Company Name" # card title
_DOMAIN_FIELD = "Company Domain" # card subtitle / fallback title
_BADGE_FIELDS = ["Company Industry", "Company Employee Range"] # header chips
_DESCRIPTION_FIELD = "Company Description"
_LINKEDIN_FIELD = "Company LinkedIn URL"
_ADDRESS_FIELD = "Company HQ Address"
# Remaining fields shown as label/value pairs, two per row.
_GRID_FIELDS = [
"Company Annual Revenue",
"Company Employee Count",
"Company HQ City",
"Last Activity",
]
# ── Palette (dark navy brand, light body for cross-client reliability) ─────
_C = {
"page_bg": "#EEF1F8",
"card_bg": "#FFFFFF",
"header_from": "#0B1D3A",
"header_to": "#173A6E",
"navy": "#0B1D3A",
"accent": "#2F6FED",
"accent_bg": "#EAF1FF",
"text": "#1F2937",
"muted": "#64748B",
"border": "#E4E9F2",
"chip_bg": "#10254A",
"chip_text": "#DCE7FF",
"stat_bg": "#F5F8FF",
"footer_bg": "#0B1D3A",
"footer_text": "#93A5C9",
}
# ── Value helpers ────────────────────────────────────────────────────────────
def _clean(value) -> Optional[str]:
"""Return a stripped display string, or None if value is empty/NaN/placeholder."""
if value is None:
return None
if isinstance(value, float) and pd.isna(value):
return None
text = str(value).strip()
if not text or text.lower() in ("nan", "none", "null", "-", "n/a", "na"):
return None
return text
def _esc(value: str) -> str:
return html.escape(value, quote=True)
def _display_label(field: str) -> str:
return field.replace("Company ", "").strip()
# ── HTML fragment builders ───────────────────────────────────────────────────
def _render_chip(text: str) -> str:
style = (
f"display:inline-block;margin:0 0 6px 6px;padding:4px 10px;"
f"background:{_C['chip_bg']};color:{_C['chip_text']};"
f"font-size:11px;font-weight:600;letter-spacing:.2px;border-radius:20px;"
f"white-space:nowrap;"
)
return f'<span style="{style}">{_esc(text)}</span>'
def _render_kv_cell(label: str, value: str, full_width: bool = False) -> str:
width_attr = "100%" if full_width else "50%"
colspan = ' colspan="2"' if full_width else ""
td_style = "padding:0 8px 14px 0;vertical-align:top;"
label_style = (
f"font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;"
f"color:{_C['muted']};margin:0 0 3px;"
)
value_style = f"font-size:13px;color:{_C['text']};line-height:1.4;word-break:break-word;"
return (
f'<td width="{width_attr}"{colspan} style="{td_style}">'
f'<div style="{label_style}">{_esc(label)}</div>'
f'<div style="{value_style}">{_esc(value)}</div>'
f"</td>"
)
def _render_card(row: "pd.Series") -> str:
name = _clean(row.get(_PRIMARY_FIELD)) or _clean(row.get(_DOMAIN_FIELD)) or "Unknown company"
domain = _clean(row.get(_DOMAIN_FIELD))
subtitle = domain if domain and domain != name else None
chips_html = "".join(
_render_chip(v) for v in (_clean(row.get(f)) for f in _BADGE_FIELDS) if v
)
# Grid: pair fields two-per-row.
grid_values = [(f, _clean(row.get(f))) for f in _GRID_FIELDS]
grid_values = [(f, v) for f, v in grid_values if v]
grid_rows = ""
for i in range(0, len(grid_values), 2):
pair = grid_values[i:i + 2]
cells = "".join(_render_kv_cell(_display_label(f), v) for f, v in pair)
if len(pair) == 1:
cells += '<td width="50%" style="padding:0;"></td>'
grid_rows += f"<tr>{cells}</tr>"
address = _clean(row.get(_ADDRESS_FIELD))
if address:
grid_rows += f"<tr>{_render_kv_cell('HQ Address', address, full_width=True)}</tr>"
grid_table = ""
if grid_rows:
grid_table = (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
f'style="margin-top:14px;border-top:1px solid {_C["border"]};padding-top:14px;">'
f"{grid_rows}</table>"
)
description = _clean(row.get(_DESCRIPTION_FIELD))
description_html = ""
if description:
desc_style = (
f"margin-top:12px;padding:10px 12px;background:{_C['stat_bg']};"
f"border-radius:6px;font-size:12.5px;line-height:1.5;color:{_C['muted']};"
f"word-break:break-word;"
)
description_html = f'<div style="{desc_style}">{_esc(description)}</div>'
linkedin = _clean(row.get(_LINKEDIN_FIELD))
linkedin_html = ""
if linkedin and linkedin.lower().startswith(("http://", "https://")):
link_style = (
f"display:inline-block;margin-top:14px;font-size:12.5px;font-weight:700;"
f"color:{_C['accent']};"
)
linkedin_html = (
f'<a href="{_esc(linkedin)}" target="_blank" rel="noopener noreferrer" '
f'style="{link_style}">View LinkedIn profile &rarr;</a>'
)
subtitle_html = ""
if subtitle:
subtitle_html = (
f'<div style="font-size:12.5px;color:{_C["muted"]};margin-top:2px;">{_esc(subtitle)}</div>'
)
card_style = (
f"background:{_C['card_bg']};border:1px solid {_C['border']};border-radius:10px;"
f"padding:18px 20px;margin-bottom:14px;"
)
return f"""
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="{card_style}">
<tr>
<td>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td valign="top">
<div style="font-size:16px;font-weight:700;color:{_C['navy']};">{_esc(name)}</div>
{subtitle_html}
</td>
<td align="right" valign="top" style="padding-left:10px;">{chips_html}</td>
</tr>
</table>
{grid_table}
{description_html}
{linkedin_html}
</td>
</tr>
</table>"""
def _build_html(df: pd.DataFrame, is_first_run: bool, csv_filename: Optional[str]) -> str:
count = len(df)
run_time = datetime.now().strftime("%d %b %Y, %H:%M")
if is_first_run:
headline = "Baseline Export Complete"
summary = (
f"This is the first run of the automation — no previous export existed for "
f"comparison. All {count} current account{'s' if count != 1 else ''} are listed below."
)
else:
headline = f"{count} New Account{'s' if count != 1 else ''} Detected"
verb = "were" if count != 1 else "was"
summary = f"{count} new account{'s' if count != 1 else ''} {verb} added since the last export."
cards_html = "".join(_render_card(row) for _, row in df.iterrows()) if count else (
f'<p style="color:{_C["muted"]};font-size:13px;">No accounts to display.</p>'
)
attachment_html = ""
if csv_filename:
attachment_html = f"""
<tr>
<td style="padding:0 32px 20px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="background:{_C['stat_bg']};border:1px solid {_C['border']};border-radius:8px;">
<tr>
<td style="padding:10px 14px;font-size:12.5px;color:{_C['text']};">
<strong>Full export attached:</strong> {_esc(csv_filename)}
<span style="color:{_C['muted']};">&nbsp;({count} row{'s' if count != 1 else ''}, all fields)</span>
</td>
</tr>
</table>
</td>
</tr>"""
return f"""<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no">
<title>Factors.AI Account Alert</title>
<!--[if mso]>
<noscript>
<xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>
</noscript>
<style>table,td,div,h1,p {{ font-family: Arial, Helvetica, sans-serif !important; }}</style>
<![endif]-->
<style>
body, table, td, a {{ -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; }}
table, td {{ mso-table-lspace:0pt; mso-table-rspace:0pt; }}
img {{ -ms-interpolation-mode:bicubic; border:0; }}
a {{ text-decoration:none; }}
@media only screen and (max-width:600px) {{
.email-container {{ width:100% !important; }}
.email-pad {{ padding-left:16px !important; padding-right:16px !important; }}
}}
</style>
</head>
<body style="margin:0;padding:0;background:{_C['page_bg']};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:{_C['page_bg']};">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" width="640" class="email-container" cellpadding="0" cellspacing="0"
style="width:640px;max-width:640px;background:{_C['card_bg']};border-radius:12px;
box-shadow:0 4px 20px rgba(11,29,58,0.08);">
<!-- Header -->
<tr>
<td bgcolor="{_C['header_from']}"
style="background:{_C['header_from']};background:linear-gradient(135deg,{_C['header_from']},{_C['header_to']});
border-radius:12px 12px 0 0;padding:28px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>
<div style="font-size:20px;font-weight:700;color:#FFFFFF;">Factors.AI &mdash; Account Alerts</div>
<div style="font-size:12.5px;color:#AFC2EA;margin-top:4px;">Run time: {run_time}</div>
</td>
</tr>
</table>
</td>
</tr>
<!-- Headline banner -->
<tr>
<td class="email-pad" style="padding:24px 32px 0;">
<div style="font-size:17px;font-weight:700;color:{_C['navy']};">{headline}</div>
<div style="font-size:13px;color:{_C['muted']};margin-top:6px;line-height:1.5;">{summary}</div>
</td>
</tr>
<!-- Attachment note -->
{f'<tr><td class="email-pad" style="padding:18px 32px 0;">' + attachment_html.replace(chr(10), "") + "</td></tr>" if False else ""}
{attachment_html}
<!-- Cards -->
<tr>
<td class="email-pad" style="padding:20px 32px 8px;">
{cards_html}
</td>
</tr>
<!-- Footer -->
<tr>
<td bgcolor="{_C['footer_bg']}" style="background:{_C['footer_bg']};border-radius:0 0 12px 12px;padding:18px 32px;">
<div style="font-size:11px;color:{_C['footer_text']};line-height:1.5;">
This is an automated message generated by the Factors.AI Account Export script.
Do not reply to this email.
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
def _build_plain_text(df: pd.DataFrame, is_first_run: bool, csv_filename: Optional[str]) -> str:
count = len(df)
lines = ["Factors.AI - Account Alerts", "=" * 32, ""]
if is_first_run:
lines.append(f"Baseline export - {count} account(s) currently in Factors.AI.")
else:
lines.append(f"{count} new account(s) detected since the last export.")
lines.append("")
for _, row in df.iterrows():
name = _clean(row.get(_PRIMARY_FIELD)) or _clean(row.get(_DOMAIN_FIELD)) or "Unknown company"
domain = _clean(row.get(_DOMAIN_FIELD))
header = name + (f" ({domain})" if domain and domain != name else "")
lines.append(f"- {header}")
for field in [*_BADGE_FIELDS, *_GRID_FIELDS, _ADDRESS_FIELD]:
val = _clean(row.get(field))
if val:
lines.append(f" {_display_label(field)}: {val}")
linkedin = _clean(row.get(_LINKEDIN_FIELD))
if linkedin:
lines.append(f" LinkedIn: {linkedin}")
lines.append("")
if csv_filename:
lines.append(f"Full export attached: {csv_filename}")
lines.append("")
lines.append("This is an automated message from the Factors.AI Account Export script.")
return "\n".join(lines)
# ── Attachment ────────────────────────────────────────────────────────────────
def _attach_csv(msg: MIMEMultipart, csv_path: Optional[str]) -> Optional[str]:
"""Attach the exported CSV to the message. Returns the filename attached, or None."""
if not csv_path:
logger.info("No downloaded_csv path provided — sending without an attachment.")
return None
path = Path(csv_path)
if not path.is_file():
logger.warning(f"downloaded_csv not found, skipping attachment: {path}")
return None
try:
data = path.read_bytes()
except OSError as exc:
logger.warning(f"Could not read downloaded_csv ({path}): {exc} — skipping attachment.")
return None
part = MIMEApplication(data, _subtype="csv")
part.add_header("Content-Disposition", "attachment", filename=path.name)
msg.attach(part)
logger.info(f"Attached CSV: {path.name} ({len(data):,} bytes)")
return path.name
# ── Public API ────────────────────────────────────────────────────────────────
def send_alert(
smtp_host: str,
smtp_port: int,
sender_email: str,
sender_password: str,
recipients: Sequence[str],
new_entries: pd.DataFrame,
downloaded_csv: Optional[str] = None,
*,
is_first_run: bool = False,
skip_if_empty: bool = True,
use_ssl: bool = False,
) -> bool:
"""
Compose and send the Factors.AI account alert email, with the raw CSV export attached.
Parameters
----------
smtp_host / smtp_port : SMTP server. Use use_ssl=True for port 465 (implicit TLS),
or leave use_ssl=False for STARTTLS (typically port 587).
sender_email : The From address.
sender_password : SMTP password / App Password.
recipients : List of To addresses. Must be non-empty.
new_entries : DataFrame of new rows to include in the digest.
downloaded_csv : Path to the full CSV export to attach. If missing/unreadable,
the email still sends without the attachment (logged as a warning).
is_first_run : True if no previous CSV existed (baseline export copy/subject).
skip_if_empty : If True (default) and new_entries is empty and this is not the
first run, no email is sent — avoids empty "0 new accounts" noise.
use_ssl : Use SMTP_SSL instead of STARTTLS.
Returns
-------
bool — True if an email was sent, False if sending was skipped (empty digest).
"""
if not recipients:
raise ValueError("recipients must contain at least one address")
if new_entries is None:
raise ValueError("new_entries cannot be None")
count = len(new_entries)
if count == 0 and not is_first_run and skip_if_empty:
logger.info("No new accounts detected — skipping email (skip_if_empty=True).")
return False
today = datetime.now().strftime("%Y-%m-%d")
subject = (
f"[Factors.AI] Baseline Export — {count} Account{'s' if count != 1 else ''} ({today})"
if is_first_run
else f"[Factors.AI] {count} New Account{'s' if count != 1 else ''} Detected ({today})"
)
msg = MIMEMultipart("mixed")
msg["Subject"] = subject
msg["From"] = sender_email
msg["To"] = ", ".join(recipients)
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid()
csv_filename = Path(downloaded_csv).name if downloaded_csv and Path(downloaded_csv).is_file() else None
alt = MIMEMultipart("alternative")
alt.attach(MIMEText(_build_plain_text(new_entries, is_first_run, csv_filename), "plain", "utf-8"))
alt.attach(MIMEText(_build_html(new_entries, is_first_run, csv_filename), "html", "utf-8"))
msg.attach(alt)
_attach_csv(msg, downloaded_csv)
logger.info(f"Connecting to {smtp_host}:{smtp_port} ...")
try:
smtp_cls = smtplib.SMTP_SSL if use_ssl else smtplib.SMTP
with smtp_cls(smtp_host, smtp_port, timeout=30) as server:
server.ehlo()
if not use_ssl:
server.starttls()
server.ehlo()
server.login(sender_email, sender_password)
server.sendmail(sender_email, list(recipients), msg.as_string())
except smtplib.SMTPException:
logger.exception(f"Failed to send Factors.AI alert email to {recipients}")
raise
logger.info(f"Email sent to: {recipients} | subject: {subject}")
return True
def save_preview(
new_entries: pd.DataFrame,
output_path: str = "preview.html",
is_first_run: bool = False,
csv_filename: Optional[str] = "factors_ai_export.csv",
) -> str:
"""Render the email to a local HTML file without sending anything — for quick design checks."""
html_body = _build_html(new_entries, is_first_run, csv_filename)
Path(output_path).write_text(html_body, encoding="utf-8")
return output_path
# """
# mailer.py — Sends an HTML email listing new Factors.AI account entries.
# """
# import logging
# import smtplib
# from datetime import datetime
# from email.mime.multipart import MIMEMultipart
# from email.mime.text import MIMEText
# import pandas as pd
# logger = logging.getLogger(__name__)
# # Columns to show in the email table (in this order)
# _EMAIL_COLS = [
# "Account Domain",
# "Company Name",
# "Company Industry",
# "Company Employee Range",
# "Company Annual Revenue",
# "Last Activity",
# ]
# # ── HTML helpers ──────────────────────────────────────────────────────────────
# _STYLES = {
# "body": "font-family:Arial,sans-serif;font-size:14px;color:#333;margin:0;padding:0;background:#f4f6f9",
# "wrapper": "max-width:900px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.12)",
# "header": "background:#4F46E5;padding:24px 32px",
# "h1": "color:#fff;margin:0;font-size:22px",
# "sub": "color:#c7d2fe;font-size:13px;margin:6px 0 0",
# "body_div": "padding:24px 32px",
# "table": "border-collapse:collapse;width:100%;font-size:13px",
# "th": "padding:10px 14px;background:#4F46E5;color:#fff;text-align:left;white-space:nowrap",
# "td_even": "padding:9px 14px;border-bottom:1px solid #eef0f3;background:#f9fafb",
# "td_odd": "padding:9px 14px;border-bottom:1px solid #eef0f3;background:#fff",
# "footer": "padding:16px 32px;background:#f4f6f9;font-size:11px;color:#9ca3af",
# }
# def _make_table(df: pd.DataFrame) -> str:
# cols = [c for c in _EMAIL_COLS if c in df.columns]
# if not cols:
# cols = list(df.columns)
# headers = "".join(
# f"<th style='{_STYLES['th']}'>{c}</th>" for c in cols
# )
# rows_html = ""
# for i, (_, row) in enumerate(df[cols].iterrows()):
# td_style = _STYLES["td_even"] if i % 2 == 0 else _STYLES["td_odd"]
# cells = "".join(
# f"<td style='{td_style}'>{str(v)}</td>" for v in row
# )
# rows_html += f"<tr>{cells}</tr>"
# tbl_style = _STYLES["table"]
# return (
# f"<table style='{tbl_style}'>"
# f"<thead><tr>{headers}</tr></thead>"
# f"<tbody>{rows_html}</tbody>"
# f"</table>"
# )
# def _build_html(df: pd.DataFrame, is_first_run: bool) -> str:
# count = len(df)
# run_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# if is_first_run:
# intro = (
# "This is the <b>first run</b> of the automation — no previous "
# "export exists for comparison. All current accounts are listed below."
# )
# else:
# intro = (
# f"<b>{count} new account{'s' if count != 1 else ''}</b> "
# f"{'have' if count != 1 else 'has'} been detected since the last export."
# )
# table_html = _make_table(df)
# return f"""
# <html>
# <body style="{_STYLES['body']}">
# <div style="{_STYLES['wrapper']}">
# <div style="{_STYLES['header']}">
# <h1 style="{_STYLES['h1']}">Factors.AI — Account Profiles Report</h1>
# <p style="{_STYLES['sub']}">Run time: {run_time}</p>
# </div>
# <div style="{_STYLES['body_div']}">
# <p>{intro}</p>
# <p style="font-size:13px;color:#6b7280">
# Total rows in this export: <b>{count}</b>
# </p>
# <br>
# {table_html}
# </div>
# <div style="{_STYLES['footer']}">
# This is an automated message generated by the Factors.AI Account
# Export script. Do not reply to this email.
# </div>
# </div>
# </body>
# </html>
# """
# # ── Public API ────────────────────────────────────────────────────────────────
# def send_alert(
# smtp_host: str,
# smtp_port: int,
# sender_email: str,
# sender_password: str,
# recipients: list[str],
# new_entries: pd.DataFrame,
# is_first_run: bool = False,
# downloaded_csv: str,
# ) -> None:
# """
# Compose and send the alert email.
# Parameters
# ----------
# smtp_host / smtp_port : SMTP server (default: Gmail STARTTLS 587).
# sender_email : The From address.
# sender_password : SMTP password / App Password.
# recipients : List of To addresses.
# new_entries : DataFrame of new rows to include.
# is_first_run : True if no previous CSV existed.
# """
# count = len(new_entries)
# today = datetime.now().strftime("%Y-%m-%d")
# subject = (
# f"[Factors.AI] First Run — {count} Accounts Exported ({today})"
# if is_first_run
# else f"[Factors.AI] {count} New Account{'s' if count != 1 else ''} Detected ({today})"
# )
# html_body = _build_html(new_entries, is_first_run)
# msg = MIMEMultipart("alternative")
# msg["Subject"] = subject
# msg["From"] = sender_email
# msg["To"] = ", ".join(recipients)
# msg.attach(MIMEText(html_body, "html", "utf-8"))
# logger.info(f"Connecting to {smtp_host}:{smtp_port} …")
# with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server:
# server.ehlo()
# server.starttls()
# server.login(sender_email, sender_password)
# server.sendmail(sender_email, recipients, msg.as_string())
# logger.info(f"Email sent to: {recipients} | subject: {subject}")