712 lines
25 KiB
Python
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 →</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']};"> ({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 — 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}")
|