feat(emails): subtask 4 — 4 complex templates (lead_forward, match_notify, digest, business_plan)

- Add lead_forward.html (brief table + contact table + optional CTA token link)
- Add lead_match_notify.html (new matching lead alert with heat badge)
- Add weekly_digest.html (leads table with Jinja2 for loop)
- Add business_plan.html (PDF ready notification with download CTA)
- Refactor 4 handlers in worker.py: send_lead_forward_email,
  notify_matching_suppliers, send_weekly_lead_digest, generate_business_plan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-25 12:10:55 +01:00
parent 1c7cdc42f2
commit c31d4a71a0
5 changed files with 172 additions and 115 deletions

View File

@@ -0,0 +1,11 @@
{% extends "emails/_base.html" %}
{% from "emails/_macros.html" import email_button %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_business_plan_heading }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p>{{ t.email_business_plan_body }}</p>
<p style="font-size:14px;color:#334155;">{{ t.email_business_plan_includes }}</p>
{{ email_button(download_url, t.email_business_plan_btn) }}
<p style="font-size:13px;color:#64748B;text-align:center;margin:12px 0 0;">{{ t.email_business_plan_quote_cta | tformat(quote_url=quote_url) }}</p>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends "emails/_base.html" %}
{% from "emails/_macros.html" import email_button, heat_badge, section_heading %}
{% block body %}
{# Yellow urgency banner #}
<p style="font-size:13px;color:#334155;margin:0 0 16px;padding:10px 14px;background-color:#FEF3C7;border-radius:6px;border-left:3px solid #F59E0B;">{{ t.email_lead_forward_urgency }}</p>
<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">{{ t.email_lead_forward_heading }} {{ heat_badge(heat) }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:8px 0 16px;">
{{ section_heading(t.email_lead_forward_section_brief) }}
<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;">
{% for label, value in brief_rows %}
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;vertical-align:top;">{{ label }}</td>
<td style="padding:4px 0;font-size:13px;color:#1E293B;">{{ value }}</td>
</tr>
{% endfor %}
</table>
{{ section_heading(t.email_lead_forward_section_contact) }}
<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;">
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_name }}</td>
<td style="padding:4px 0;font-size:14px;color:#0F172A;font-weight:600;">{{ contact_name }}</td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_email }}</td>
<td style="padding:4px 0;font-size:14px;"><a href="mailto:{{ contact_email }}" style="color:#1D4ED8;font-weight:600;text-decoration:none;">{{ contact_email }}</a></td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_phone }}</td>
<td style="padding:4px 0;font-size:13px;color:#1E293B;">{{ contact_phone }}</td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_company }}</td>
<td style="padding:4px 0;font-size:13px;color:#1E293B;">{{ contact_company }}</td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_role }}</td>
<td style="padding:4px 0;font-size:13px;color:#1E293B;">{{ stakeholder_type }}</td>
</tr>
</table>
{{ email_button(base_url ~ "/suppliers/leads", t.email_lead_forward_btn) }}
<p style="font-size:13px;color:#64748B;text-align:center;margin:8px 0 0;">{{ t.email_lead_forward_reply_direct | tformat(contact_email=contact_email) }}</p>
{%- if cta_url %}
<p style="font-size:12px;color:#94A3B8;text-align:center;margin:16px 0 0;">
<a href="{{ cta_url }}" style="color:#94A3B8;">&#10003; Mark as contacted</a>
</p>
{%- endif %}
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "emails/_base.html" %}
{% from "emails/_macros.html" import email_button, heat_badge %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:18px;">New [{{ heat }}] lead in {{ country }} {{ heat_badge(heat) }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p style="font-size:14px;color:#334155;">A new project brief has been submitted that matches your service area.</p>
<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;">
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">Facility</td>
<td style="font-size:13px;color:#1E293B;">{{ facility_type }}</td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">Courts</td>
<td style="font-size:13px;color:#1E293B;">{{ courts }}</td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">Country</td>
<td style="font-size:13px;color:#1E293B;">{{ country }}</td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">Timeline</td>
<td style="font-size:13px;color:#1E293B;">{{ timeline or "-" }}</td>
</tr>
</table>
<p style="font-size:13px;color:#64748B;">Contact details are available after unlocking. Credits required: {{ credit_cost }}.</p>
{{ email_button(base_url ~ "/suppliers/leads", "View lead feed") }}
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "emails/_base.html" %}
{% from "emails/_macros.html" import email_button, heat_badge_sm %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:18px;">
Your weekly lead digest &mdash; {{ leads | length }} new {{ "lead" if leads | length == 1 else "leads" }}
</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p style="font-size:14px;color:#334155;">New matching leads in your service area this week:</p>
<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;width:100%;">
<thead>
<tr>
<th style="text-align:left;font-size:11px;color:#94A3B8;padding:0 12px 6px 0;text-transform:uppercase;">Project</th>
<th style="text-align:left;font-size:11px;color:#94A3B8;padding:0 12px 6px 0;text-transform:uppercase;">Country</th>
<th style="text-align:left;font-size:11px;color:#94A3B8;text-transform:uppercase;">Timeline</th>
</tr>
</thead>
<tbody>
{% for lead in leads %}
<tr>
<td style="padding:6px 12px 6px 0;font-size:13px;color:#1E293B;">
{{ heat_badge_sm(lead.heat | upper) }} {{ lead.facility_type or "Padel" }}, {{ lead.court_count or "?" }} courts
</td>
<td style="padding:6px 12px 6px 0;font-size:13px;color:#64748B;">{{ lead.country or "-" }}</td>
<td style="padding:6px 0;font-size:13px;color:#64748B;">{{ lead.timeline or "-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ email_button(base_url ~ "/suppliers/leads", "Unlock leads →") }}
{% endblock %}

View File

@@ -384,15 +384,6 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
subject = f"[{heat}] New padel project in {country} \u2014 {courts} courts, \u20ac{budget}"
# Heat badge color
heat_colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"}
heat_bg = heat_colors.get(heat, "#2563EB")
heat_badge = (
f'<span style="display:inline-block;padding:2px 8px;border-radius:4px;'
f'background-color:{heat_bg};color:#FFFFFF;font-size:11px;font-weight:700;'
f'letter-spacing:0.04em;vertical-align:middle;margin-left:8px;">{heat}</span>'
)
tl = lambda key: _t(key, lang) # noqa: E731
brief_rows = [
@@ -405,50 +396,11 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
(tl("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
]
brief_html = ""
for label, value in brief_rows:
brief_html += (
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;vertical-align:top">{label}</td>'
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{value}</td></tr>'
)
contact_name = lead["contact_name"] or "-"
contact_phone = lead["contact_phone"] or "-"
# Contact section with prominent email
contact_html = (
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_name")}</td>'
f'<td style="padding:4px 0;font-size:14px;color:#0F172A;font-weight:600">{contact_name}</td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_email")}</td>'
f'<td style="padding:4px 0;font-size:14px;"><a href="mailto:{contact_email}" style="color:#1D4ED8;font-weight:600;text-decoration:none;">{contact_email}</a></td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_phone")}</td>'
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{contact_phone}</td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_company")}</td>'
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{lead["contact_company"] or "-"}</td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_role")}</td>'
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{lead["stakeholder_type"] or "-"}</td></tr>'
)
preheader_parts = [f"{facility_type} project"]
if timeline:
preheader_parts.append(f"{timeline} timeline")
preheader_parts.append(_t("email_lead_forward_preheader_suffix", lang))
body = (
f'<p style="font-size:13px;color:#334155;margin:0 0 16px;padding:10px 14px;'
f'background-color:#FEF3C7;border-radius:6px;border-left:3px solid #F59E0B;">'
f'{_t("email_lead_forward_urgency", lang)}</p>'
f'<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">{tl("email_lead_forward_heading")} {heat_badge}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:8px 0 16px;">'
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{tl("email_lead_forward_section_brief")}</h3>'
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">{brief_html}</table>'
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{tl("email_lead_forward_section_contact")}</h3>'
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">{contact_html}</table>'
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", tl("email_lead_forward_btn"))}'
f'<p style="font-size:13px;color:#64748B;text-align:center;margin:8px 0 0;">'
f'{_t("email_lead_forward_reply_direct", lang, contact_email=contact_email)}</p>'
)
# Send to supplier contact email or general contact
to_email = supplier.get("contact_email") or supplier.get("contact") or ""
if not to_email:
@@ -458,16 +410,25 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
# Generate one-click "I've contacted this lead" CTA token
cta_token = secrets.token_urlsafe(24)
cta_url = f"{config.BASE_URL}/suppliers/leads/cta/{cta_token}"
body += (
f'<p style="font-size:12px;color:#94A3B8;text-align:center;margin:16px 0 0;">'
f'<a href="{cta_url}" style="color:#94A3B8;">'
f'&#10003; Mark as contacted</a></p>'
html = render_email_template(
"emails/lead_forward.html",
lang=lang,
heat=heat,
brief_rows=brief_rows,
contact_name=lead["contact_name"] or "-",
contact_email=contact_email,
contact_phone=lead["contact_phone"] or "-",
contact_company=lead["contact_company"] or "-",
stakeholder_type=lead["stakeholder_type"] or "-",
cta_url=cta_url,
preheader=", ".join(preheader_parts),
)
await send_email(
to=to_email,
subject=subject,
html=_email_wrap(body, lang, preheader=", ".join(preheader_parts)),
html=html,
from_addr=EMAIL_ADDRESSES["leads"],
email_type="lead_forward",
)
@@ -549,30 +510,22 @@ async def handle_notify_matching_suppliers(payload: dict) -> None:
if not to_email:
continue
body = (
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:18px;">'
f'New [{heat}] lead in {country}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
f'<p style="font-size:14px;color:#334155;">A new project brief has been submitted that matches your service area.</p>'
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">'
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">Facility</td>'
f'<td style="font-size:13px;color:#1E293B">{facility_type}</td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">Courts</td>'
f'<td style="font-size:13px;color:#1E293B">{courts}</td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">Country</td>'
f'<td style="font-size:13px;color:#1E293B">{country}</td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">Timeline</td>'
f'<td style="font-size:13px;color:#1E293B">{timeline or "-"}</td></tr>'
f'</table>'
f'<p style="font-size:13px;color:#64748B;">'
f'Contact details are available after unlocking. Credits required: {lead.get("credit_cost", "?")}.</p>'
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", "View lead feed")}'
notify_html = render_email_template(
"emails/lead_match_notify.html",
lang=lang,
heat=heat,
country=country,
facility_type=facility_type,
courts=courts,
timeline=timeline,
credit_cost=lead.get("credit_cost", "?"),
preheader=f"New matching lead in {country}",
)
await send_email(
to=to_email,
subject=f"[{heat}] New {facility_type} project in {country}{courts} courts",
html=_email_wrap(body, lang, preheader=f"New matching lead in {country}"),
html=notify_html,
from_addr=EMAIL_ADDRESSES["leads"],
email_type="lead_match_notify",
)
@@ -617,48 +570,27 @@ async def handle_send_weekly_lead_digest(payload: dict) -> None:
if not new_leads:
continue
lead_rows_html = ""
for ld in new_leads:
heat = (ld["heat_score"] or "cool").upper()
heat_colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"}
hc = heat_colors.get(heat, "#2563EB")
badge = (
f'<span style="display:inline-block;padding:1px 6px;border-radius:4px;'
f'background-color:{hc};color:#fff;font-size:10px;font-weight:700">{heat}</span>'
)
lead_rows_html += (
f'<tr>'
f'<td style="padding:6px 12px 6px 0;font-size:13px;color:#1E293B">'
f'{badge} {ld["facility_type"] or "Padel"}, {ld["court_count"] or "?"} courts</td>'
f'<td style="padding:6px 12px 6px 0;font-size:13px;color:#64748B">{ld["country"] or "-"}</td>'
f'<td style="padding:6px 0;font-size:13px;color:#64748B">{ld["timeline"] or "-"}</td>'
f'</tr>'
)
body = (
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:18px;">'
f'Your weekly lead digest — {len(new_leads)} new {"lead" if len(new_leads) == 1 else "leads"}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
f'<p style="font-size:14px;color:#334155;">New matching leads in your service area this week:</p>'
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;width:100%">'
f'<thead><tr>'
f'<th style="text-align:left;font-size:11px;color:#94A3B8;padding:0 12px 6px 0;text-transform:uppercase">Project</th>'
f'<th style="text-align:left;font-size:11px;color:#94A3B8;padding:0 12px 6px 0;text-transform:uppercase">Country</th>'
f'<th style="text-align:left;font-size:11px;color:#94A3B8;text-transform:uppercase">Timeline</th>'
f'</tr></thead>'
f'<tbody>{lead_rows_html}</tbody>'
f'</table>'
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", "Unlock leads →")}'
)
# Normalise lead dicts for template — heat_score → heat (uppercase)
digest_leads = [
{**ld, "heat": (ld["heat_score"] or "cool").upper()}
for ld in new_leads
]
area_summary = ", ".join(countries[:3])
if len(countries) > 3:
area_summary += f" +{len(countries) - 3}"
digest_html = render_email_template(
"emails/weekly_digest.html",
lang="en",
leads=digest_leads,
preheader=f"{len(new_leads)} new leads matching your service area",
)
await send_email(
to=to_email,
subject=f"{len(new_leads)} new padel {'lead' if len(new_leads) == 1 else 'leads'} in {area_summary}",
html=_email_wrap(body, "en", preheader=f"{len(new_leads)} new leads matching your service area"),
html=digest_html,
from_addr=EMAIL_ADDRESSES["leads"],
email_type="weekly_digest",
)
@@ -776,19 +708,17 @@ async def handle_generate_business_plan(payload: dict) -> None:
export_token = export_row["token"]
user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,))
if user:
body = (
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_business_plan_heading", language)}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
f'<p>{_t("email_business_plan_body", language)}</p>'
f'<p style="font-size:14px;color:#334155;">{_t("email_business_plan_includes", language)}</p>'
f'{_email_button(f"{config.BASE_URL}/planner/export/{export_token}", _t("email_business_plan_btn", language))}'
f'<p style="font-size:13px;color:#64748B;text-align:center;margin:12px 0 0;">'
f'{_t("email_business_plan_quote_cta", language, quote_url=f"{config.BASE_URL}/{language}/leads/quote")}</p>'
bp_html = render_email_template(
"emails/business_plan.html",
lang=language,
download_url=f"{config.BASE_URL}/planner/export/{export_token}",
quote_url=f"{config.BASE_URL}/{language}/leads/quote",
preheader=_t("email_business_plan_preheader", language),
)
await send_email(
to=user["email"],
subject=_t("email_business_plan_subject", language),
html=_email_wrap(body, language, preheader=_t("email_business_plan_preheader", language)),
html=bp_html,
from_addr=EMAIL_ADDRESSES["transactional"],
email_type="business_plan",
)