feat(emails): subtask 1-2 — email_templates.py foundation + 3 simple templates

- Add email_templates.py: standalone Jinja2 env, render_email_template(),
  EMAIL_TEMPLATE_REGISTRY with sample_data functions for all 11 email types
- Add templates/emails/_base.html: direct transliteration of _email_wrap()
- Add templates/emails/_macros.html: email_button, heat_badge, heat_badge_sm,
  section_heading, info_box macros
- Add magic_link.html, welcome.html, supplier_enquiry.html templates
- Refactor 3 handlers in worker.py to use render_email_template()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-25 11:58:29 +01:00
parent e5960c08ff
commit daf1945d5b
7 changed files with 500 additions and 39 deletions

View File

@@ -0,0 +1,306 @@
"""
Standalone Jinja2 email template renderer.
Used by both the worker (outside Quart request context) and admin gallery routes.
Creates a module-level Environment pointing at the same templates/ directory
used by the web app, so templates share the same file tree.
Usage:
from .email_templates import render_email_template, EMAIL_TEMPLATE_REGISTRY
html = render_email_template("emails/magic_link.html", lang="en", link=link, expiry_minutes=15)
"""
from pathlib import Path
import jinja2
from .core import config, utcnow
from .i18n import get_translations
_TEMPLATES_DIR = Path(__file__).parent / "templates"
# Standalone environment — not tied to Quart's request context.
# autoescape=True: user-supplied data (names, emails, messages) is auto-escaped.
# Trusted HTML sections use the `| safe` filter explicitly.
_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(str(_TEMPLATES_DIR)),
autoescape=True,
undefined=jinja2.StrictUndefined,
)
def _tformat(s: str, **kwargs) -> str:
"""Jinja filter: interpolate {placeholders} into a translation string.
Mirrors the `tformat` filter registered in app.py so email templates
and web templates use the same syntax:
{{ t.some_key | tformat(name=supplier.name, count=n) }}
"""
if not kwargs:
return s
return s.format(**kwargs)
_env.filters["tformat"] = _tformat
def render_email_template(template_name: str, lang: str = "en", **kwargs) -> str:
"""Render an email template with standard context injected.
Args:
template_name: Path relative to templates/ (e.g. "emails/magic_link.html").
lang: Language code ("en" or "de"). Used for translations + html lang attr.
**kwargs: Additional context variables passed to the template.
Returns:
Rendered HTML string containing a full <!DOCTYPE html> document.
"""
assert lang in ("en", "de"), f"Unsupported lang: {lang!r}"
assert template_name.startswith("emails/"), f"Expected emails/ prefix: {template_name!r}"
translations = get_translations(lang)
year = utcnow().year
# Pre-interpolate footer strings so templates don't need to call tformat on them.
tagline = translations.get("email_footer_tagline", "")
copyright_text = translations.get("email_footer_copyright", "").format(
year=year, app_name=config.APP_NAME
)
context = {
"lang": lang,
"app_name": config.APP_NAME,
"base_url": config.BASE_URL,
"t": translations,
"tagline": tagline,
"copyright_text": copyright_text,
**kwargs,
}
tmpl = _env.get_template(template_name)
rendered = tmpl.render(**context)
assert "<!DOCTYPE html>" in rendered, f"Template {template_name!r} must produce a DOCTYPE document"
assert "padelnomics" in rendered.lower(), f"Template {template_name!r} must include the wordmark"
return rendered
# =============================================================================
# Template registry — used by admin gallery for sample preview rendering
# =============================================================================
def _magic_link_sample(lang: str) -> dict:
return {
"link": f"{config.BASE_URL}/auth/verify?token=sample_token_abc123",
"expiry_minutes": 15,
"preheader": get_translations(lang).get("email_magic_link_preheader", "").format(expiry_minutes=15),
}
def _quote_verification_sample(lang: str) -> dict:
t = get_translations(lang)
court_count = "4"
return {
"link": f"{config.BASE_URL}/{lang}/leads/verify?token=verify123&lead=lead456",
"first_name": "Alex",
"court_count": court_count,
"facility_type": "Indoor Padel Club",
"country": "Germany",
"preheader": t.get("email_quote_verify_preheader_courts", "").format(court_count=court_count),
}
def _welcome_sample(lang: str) -> dict:
t = get_translations(lang)
return {
"first_name": "Maria",
"preheader": t.get("email_welcome_preheader", ""),
}
def _waitlist_supplier_sample(lang: str) -> dict:
t = get_translations(lang)
return {
"plan_name": "Growth",
"preheader": t.get("email_waitlist_supplier_preheader", ""),
}
def _waitlist_general_sample(lang: str) -> dict:
t = get_translations(lang)
return {
"preheader": t.get("email_waitlist_general_preheader", ""),
}
def _lead_matched_sample(lang: str) -> dict:
t = get_translations(lang)
return {
"first_name": "Thomas",
"facility_type": "padel",
"court_count": "6",
"country": "Austria",
"preheader": t.get("email_lead_matched_preheader", ""),
}
def _lead_forward_sample(lang: str) -> dict:
return {
"heat": "HOT",
"country": "Spain",
"courts": "8",
"budget": "450000",
"facility_type": "Outdoor Padel Club",
"timeline": "Q3 2025",
"contact_email": "ceo@padelclub.es",
"contact_name": "Carlos Rivera",
"contact_phone": "+34 612 345 678",
"contact_company": "PadelClub Madrid SL",
"stakeholder_type": "Developer / Investor",
"build_context": "New build",
"glass_type": "Panoramic",
"lighting_type": "LED",
"location": "Madrid",
"location_status": "Site confirmed",
"financing_status": "Self-financed",
"services_needed": "Full turnkey construction",
"additional_info": "Seeking experienced international suppliers only.",
"cta_url": f"{config.BASE_URL}/suppliers/leads/cta/sample_cta_token",
"preheader": "Outdoor Padel Club project · Q3 2025 timeline — contact details inside",
"brief_rows": [
("Facility", "Outdoor Padel Club (New build)"),
("Courts", "8 | Glass: Panoramic | Lighting: LED"),
("Location", "Madrid, Spain"),
("Timeline", "Q3 2025 | Budget: €450000"),
("Phase", "Site confirmed | Financing: Self-financed"),
("Services", "Full turnkey construction"),
("Additional Info", "Seeking experienced international suppliers only."),
],
}
def _lead_match_notify_sample(lang: str) -> dict:
return {
"heat": "WARM",
"country": "Netherlands",
"courts": "4",
"facility_type": "Indoor Padel",
"timeline": "Q1 2026",
"credit_cost": 2,
"preheader": "New matching lead in Netherlands",
}
def _weekly_digest_sample(lang: str) -> dict:
return {
"leads": [
{"heat": "HOT", "facility_type": "Outdoor Padel", "court_count": "6", "country": "Germany", "timeline": "Q2 2025"},
{"heat": "WARM", "facility_type": "Indoor Club", "court_count": "4", "country": "Austria", "timeline": "Q3 2025"},
{"heat": "COOL", "facility_type": "Padel Centre", "court_count": "8", "country": "Switzerland", "timeline": "2026"},
],
"preheader": "3 new leads matching your service area",
}
def _business_plan_sample(lang: str) -> dict:
t = get_translations(lang)
return {
"download_url": f"{config.BASE_URL}/planner/export/sample_export_token",
"quote_url": f"{config.BASE_URL}/{lang}/leads/quote",
"preheader": t.get("email_business_plan_preheader", ""),
}
def _admin_compose_sample(lang: str) -> dict:
return {
"body_html": "<p>Hello,</p><p>This is a test message from the admin compose panel.</p><p>Best regards,<br>Padelnomics Team</p>",
"preheader": "Test message from admin",
}
# Registry entry shape:
# template: path relative to templates/
# label: human-readable name shown in gallery
# description: one-line description
# email_type: email_type value stored in email_log (for cross-linking)
# sample_data: callable(lang) → dict of template context
EMAIL_TEMPLATE_REGISTRY: dict[str, dict] = {
"magic_link": {
"template": "emails/magic_link.html",
"label": "Magic Link",
"description": "Passwordless sign-in link sent to users requesting access.",
"email_type": "magic_link",
"sample_data": _magic_link_sample,
},
"quote_verification": {
"template": "emails/quote_verification.html",
"label": "Quote Verification",
"description": "Email address verification for new project quote requests.",
"email_type": "quote_verification",
"sample_data": _quote_verification_sample,
},
"welcome": {
"template": "emails/welcome.html",
"label": "Welcome",
"description": "Sent to new users after their first successful sign-in.",
"email_type": "welcome",
"sample_data": _welcome_sample,
},
"waitlist_supplier": {
"template": "emails/waitlist_supplier.html",
"label": "Waitlist — Supplier",
"description": "Confirmation for suppliers who joined the Growth/Pro waitlist.",
"email_type": "waitlist",
"sample_data": _waitlist_supplier_sample,
},
"waitlist_general": {
"template": "emails/waitlist_general.html",
"label": "Waitlist — General",
"description": "Confirmation for general sign-up waitlist submissions.",
"email_type": "waitlist",
"sample_data": _waitlist_general_sample,
},
"lead_matched": {
"template": "emails/lead_matched.html",
"label": "Lead Matched",
"description": "Notifies the project owner that suppliers are now reviewing their brief.",
"email_type": "lead_matched",
"sample_data": _lead_matched_sample,
},
"lead_forward": {
"template": "emails/lead_forward.html",
"label": "Lead Forward",
"description": "Full project brief sent to a supplier after they unlock a lead.",
"email_type": "lead_forward",
"sample_data": _lead_forward_sample,
},
"lead_match_notify": {
"template": "emails/lead_match_notify.html",
"label": "Lead Match Notify",
"description": "Notifies matching suppliers that a new lead is available in their area.",
"email_type": "lead_match_notify",
"sample_data": _lead_match_notify_sample,
},
"weekly_digest": {
"template": "emails/weekly_digest.html",
"label": "Weekly Digest",
"description": "Monday digest of new leads matching a supplier's service area.",
"email_type": "weekly_digest",
"sample_data": _weekly_digest_sample,
},
"business_plan": {
"template": "emails/business_plan.html",
"label": "Business Plan Ready",
"description": "Notifies the user when their business plan PDF export is ready.",
"email_type": "business_plan",
"sample_data": _business_plan_sample,
},
"admin_compose": {
"template": "emails/admin_compose.html",
"label": "Admin Compose",
"description": "Branded wrapper used for ad-hoc emails sent from the compose panel.",
"email_type": "admin_compose",
"sample_data": _admin_compose_sample,
},
}

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ app_name }}</title>
</head>
<body style="margin:0;padding:0;background-color:#F1F5F9;font-family:Helvetica,Arial,sans-serif;">
{%- if preheader %}
{# Hidden preheader trick: visible text + invisible padding to prevent
email clients from pulling body text into the preview. #}
<span style="display:none;font-size:1px;color:#F1F5F9;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">{{ preheader }}{% for _ in range(30) %}&#847; &zwnj; {% endfor %}</span>
{%- endif %}
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#F1F5F9;padding:40px 16px;">
<tr><td align="center">
<table width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background-color:#FFFFFF;border-radius:10px;border:1px solid #E2E8F0;overflow:hidden;">
<!-- Blue accent border -->
<tr><td style="height:3px;background-color:#1D4ED8;font-size:0;line-height:0;">&nbsp;</td></tr>
<!-- Wordmark header -->
<tr><td style="background-color:#0F172A;padding:24px 36px;">
<a href="{{ base_url }}" style="text-decoration:none;">
<span style="color:#FFFFFF;font-size:18px;font-weight:800;letter-spacing:-0.02em;font-family:'Bricolage Grotesque',Georgia,'Times New Roman',serif;">padelnomics</span>
</a>
</td></tr>
<!-- Body -->
<tr><td style="padding:36px;color:#334155;font-size:15px;line-height:1.65;">
{% block body %}{% endblock %}
</td></tr>
<!-- Divider -->
<tr><td style="height:1px;background-color:#E2E8F0;"></td></tr>
<!-- Footer -->
<tr><td style="padding:20px 36px;background-color:#F8FAFC;">
<p style="margin:0 0 6px;font-size:12px;color:#94A3B8;text-align:center;">
<a href="{{ base_url }}" style="color:#64748B;text-decoration:none;font-weight:500;">padelnomics.io</a>
&nbsp;&middot;&nbsp;
{{ tagline }}
</p>
<p style="margin:0;font-size:11px;color:#CBD5E1;text-align:center;">
{{ copyright_text }}
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,61 @@
{#
Shared macros for transactional email templates.
Import in child templates:
{% from "emails/_macros.html" import email_button, heat_badge, section_heading, info_box %}
#}
{# ─── CTA Button ─────────────────────────────────────────────────────────────
Table-based blue button — works in all major email clients.
Uses display:block for full-width tap target on mobile.
#}
{% macro email_button(url, label) %}
<table cellpadding="0" cellspacing="0" width="100%" style="margin:28px 0 8px;">
<tr><td style="background-color:#1D4ED8;border-radius:8px;text-align:center;">
<a href="{{ url }}" style="display:block;padding:14px 32px;color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;letter-spacing:-0.01em;">{{ label }}</a>
</td></tr>
</table>
{% endmacro %}
{# ─── Heat Badge ─────────────────────────────────────────────────────────────
Inline colored badge: HOT (red), WARM (orange), COOL (blue).
heat: uppercase string "HOT" | "WARM" | "COOL"
#}
{% macro heat_badge(heat) %}
{%- set colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"} -%}
{%- set bg = colors.get(heat, "#2563EB") -%}
<span style="display:inline-block;padding:2px 8px;border-radius:4px;background-color:{{ bg }};color:#FFFFFF;font-size:11px;font-weight:700;letter-spacing:0.04em;vertical-align:middle;margin-left:8px;">{{ heat }}</span>
{%- endmacro %}
{# ─── Small heat badge (compact variant for table rows) ──────────────────────
1px smaller padding, used in weekly_digest lead table rows.
#}
{% macro heat_badge_sm(heat) %}
{%- set colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"} -%}
{%- set bg = colors.get(heat, "#2563EB") -%}
<span style="display:inline-block;padding:1px 6px;border-radius:4px;background-color:{{ bg }};color:#fff;font-size:10px;font-weight:700;">{{ heat }}</span>
{%- endmacro %}
{# ─── Section Heading ─────────────────────────────────────────────────────────
Small uppercase label above a data table section.
#}
{% macro section_heading(text) %}
<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px;">{{ text }}</h3>
{% endmacro %}
{# ─── Info Box ────────────────────────────────────────────────────────────────
Left-bordered callout box. color: "blue" (default) or "yellow".
#}
{% macro info_box(text, color="blue") %}
{%- if color == "yellow" -%}
{%- set bg = "#FEF3C7" -%}{%- set border = "#F59E0B" -%}
{%- else -%}
{%- set bg = "#F0F9FF" -%}{%- set border = "#1D4ED8" -%}
{%- endif -%}
<p style="font-size:13px;color:#334155;margin:0 0 16px;padding:10px 14px;background-color:{{ bg }};border-radius:6px;border-left:3px solid {{ border }};">{{ text }}</p>
{% endmacro %}

View File

@@ -0,0 +1,12 @@
{% 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_magic_link_heading | tformat(app_name=app_name) }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p>{{ t.email_magic_link_body | tformat(expiry_minutes=expiry_minutes) }}</p>
{{ email_button(link, t.email_magic_link_btn) }}
<p style="font-size:13px;color:#94A3B8;">{{ t.email_magic_link_fallback }}</p>
<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{{ link }}</p>
<p style="font-size:13px;color:#94A3B8;">{{ t.email_magic_link_ignore }}</p>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "emails/_base.html" %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_enquiry_heading | tformat(contact_name=contact_name) }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p>{{ t.email_enquiry_body | tformat(supplier_name=supplier_name) }}</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;">
<tr>
<td style="padding:6px 0;color:#64748B;width:120px;">{{ t.email_enquiry_lbl_from }}</td>
<td style="padding:6px 0;"><strong>{{ contact_name }}</strong> &lt;{{ contact_email }}&gt;</td>
</tr>
<tr>
<td style="padding:6px 0;color:#64748B;vertical-align:top;">{{ t.email_enquiry_lbl_message }}</td>
<td style="padding:6px 0;white-space:pre-wrap;">{{ message }}</td>
</tr>
</table>
<p style="font-size:13px;color:#64748B;">{{ t.email_enquiry_respond_fast }}</p>
<p style="font-size:13px;color:#64748B;">{{ t.email_enquiry_reply | tformat(contact_email=contact_email) }}</p>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% 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_welcome_heading | tformat(app_name=app_name) }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p>{{ t.email_welcome_greeting | tformat(first_name=first_name) }}</p>
<p>{{ t.email_welcome_body }}</p>
<p style="font-size:14px;font-weight:600;color:#0F172A;margin:20px 0 8px;">{{ t.email_welcome_quickstart_heading }}</p>
<table cellpadding="0" cellspacing="0" style="margin:0 0 20px;font-size:14px;">
<tr>
<td style="padding:4px 10px 4px 0;color:#1D4ED8;">&#9679;</td>
<td style="padding:4px 0;"><a href="{{ base_url }}/planner" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{{ t.email_welcome_link_planner }}</a></td>
</tr>
<tr>
<td style="padding:4px 10px 4px 0;color:#1D4ED8;">&#9679;</td>
<td style="padding:4px 0;"><a href="{{ base_url }}/{{ lang }}/markets" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{{ t.email_welcome_link_markets }}</a></td>
</tr>
<tr>
<td style="padding:4px 10px 4px 0;color:#1D4ED8;">&#9679;</td>
<td style="padding:4px 0;"><a href="{{ base_url }}/{{ lang }}/leads/quote" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{{ t.email_welcome_link_quotes }}</a></td>
</tr>
</table>
{{ email_button(base_url ~ "/planner", t.email_welcome_btn) }}
{% endblock %}

View File

@@ -22,6 +22,7 @@ from .core import (
utcnow, utcnow,
utcnow_iso, utcnow_iso,
) )
from .email_templates import render_email_template
from .i18n import get_translations from .i18n import get_translations
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -228,20 +229,18 @@ async def handle_send_magic_link(payload: dict) -> None:
logger.debug("MAGIC LINK for %s: %s", payload["email"], link) logger.debug("MAGIC LINK for %s: %s", payload["email"], link)
expiry_minutes = config.MAGIC_LINK_EXPIRY_MINUTES expiry_minutes = config.MAGIC_LINK_EXPIRY_MINUTES
body = ( html = render_email_template(
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_magic_link_heading", lang, app_name=config.APP_NAME)}</h2>' "emails/magic_link.html",
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">' lang=lang,
f'<p>{_t("email_magic_link_body", lang, expiry_minutes=expiry_minutes)}</p>' link=link,
f'{_email_button(link, _t("email_magic_link_btn", lang))}' expiry_minutes=expiry_minutes,
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_magic_link_fallback", lang)}</p>' preheader=_t("email_magic_link_preheader", lang, expiry_minutes=expiry_minutes),
f'<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{link}</p>'
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_magic_link_ignore", lang)}</p>'
) )
await send_email( await send_email(
to=payload["email"], to=payload["email"],
subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME), subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME),
html=_email_wrap(body, lang, preheader=_t("email_magic_link_preheader", lang, expiry_minutes=expiry_minutes)), html=html,
from_addr=EMAIL_ADDRESSES["transactional"], from_addr=EMAIL_ADDRESSES["transactional"],
email_type="magic_link", email_type="magic_link",
) )
@@ -315,27 +314,17 @@ async def handle_send_welcome(payload: dict) -> None:
name_parts = (payload.get("name") or "").split() name_parts = (payload.get("name") or "").split()
first_name = name_parts[0] if name_parts else "there" first_name = name_parts[0] if name_parts else "there"
body = ( html = render_email_template(
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_welcome_heading", lang, app_name=config.APP_NAME)}</h2>' "emails/welcome.html",
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">' lang=lang,
f'<p>{_t("email_welcome_greeting", lang, first_name=first_name)}</p>' first_name=first_name,
f'<p>{_t("email_welcome_body", lang)}</p>' preheader=_t("email_welcome_preheader", lang),
f'<p style="font-size:14px;font-weight:600;color:#0F172A;margin:20px 0 8px;">{_t("email_welcome_quickstart_heading", lang)}</p>'
f'<table cellpadding="0" cellspacing="0" style="margin:0 0 20px;font-size:14px;">'
f'<tr><td style="padding:4px 10px 4px 0;color:#1D4ED8;">&#9679;</td>'
f'<td style="padding:4px 0;"><a href="{config.BASE_URL}/planner" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{_t("email_welcome_link_planner", lang)}</a></td></tr>'
f'<tr><td style="padding:4px 10px 4px 0;color:#1D4ED8;">&#9679;</td>'
f'<td style="padding:4px 0;"><a href="{config.BASE_URL}/{lang}/markets" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{_t("email_welcome_link_markets", lang)}</a></td></tr>'
f'<tr><td style="padding:4px 10px 4px 0;color:#1D4ED8;">&#9679;</td>'
f'<td style="padding:4px 0;"><a href="{config.BASE_URL}/{lang}/leads/quote" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{_t("email_welcome_link_quotes", lang)}</a></td></tr>'
f'</table>'
f'{_email_button(f"{config.BASE_URL}/planner", _t("email_welcome_btn", lang))}'
) )
await send_email( await send_email(
to=payload["email"], to=payload["email"],
subject=_t("email_welcome_subject", lang), subject=_t("email_welcome_subject", lang),
html=_email_wrap(body, lang, preheader=_t("email_welcome_preheader", lang)), html=html,
from_addr=EMAIL_ADDRESSES["transactional"], from_addr=EMAIL_ADDRESSES["transactional"],
email_type="welcome", email_type="welcome",
) )
@@ -727,25 +716,20 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None:
contact_email = payload.get("contact_email", "") contact_email = payload.get("contact_email", "")
message = payload.get("message", "") message = payload.get("message", "")
body = ( html = render_email_template(
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">' "emails/supplier_enquiry.html",
f'{_t("email_enquiry_heading", lang, contact_name=contact_name)}</h2>' lang=lang,
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">' supplier_name=supplier_name,
f'<p>{_t("email_enquiry_body", lang, supplier_name=supplier_name)}</p>' contact_name=contact_name,
f'<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">' contact_email=contact_email,
f'<tr><td style="padding:6px 0;color:#64748B;width:120px">{_t("email_enquiry_lbl_from", lang)}</td>' message=message,
f'<td style="padding:6px 0"><strong>{contact_name}</strong> &lt;{contact_email}&gt;</td></tr>' preheader=_t("email_enquiry_preheader", lang),
f'<tr><td style="padding:6px 0;color:#64748B;vertical-align:top">{_t("email_enquiry_lbl_message", lang)}</td>'
f'<td style="padding:6px 0;white-space:pre-wrap">{message}</td></tr>'
f'</table>'
f'<p style="font-size:13px;color:#64748B;">{_t("email_enquiry_respond_fast", lang)}</p>'
f'<p style="font-size:13px;color:#64748B;">{_t("email_enquiry_reply", lang, contact_email=contact_email)}</p>'
) )
await send_email( await send_email(
to=supplier_email, to=supplier_email,
subject=_t("email_enquiry_subject", lang, contact_name=contact_name), subject=_t("email_enquiry_subject", lang, contact_name=contact_name),
html=_email_wrap(body, lang, preheader=_t("email_enquiry_preheader", lang)), html=html,
from_addr=EMAIL_ADDRESSES["transactional"], from_addr=EMAIL_ADDRESSES["transactional"],
email_type="supplier_enquiry", email_type="supplier_enquiry",
) )