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:
306
web/src/padelnomics/email_templates.py
Normal file
306
web/src/padelnomics/email_templates.py
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
54
web/src/padelnomics/templates/emails/_base.html
Normal file
54
web/src/padelnomics/templates/emails/_base.html
Normal 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) %}͏ ‌ {% 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;"> </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>
|
||||||
|
·
|
||||||
|
{{ 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>
|
||||||
61
web/src/padelnomics/templates/emails/_macros.html
Normal file
61
web/src/padelnomics/templates/emails/_macros.html
Normal 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 %}
|
||||||
12
web/src/padelnomics/templates/emails/magic_link.html
Normal file
12
web/src/padelnomics/templates/emails/magic_link.html
Normal 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 %}
|
||||||
19
web/src/padelnomics/templates/emails/supplier_enquiry.html
Normal file
19
web/src/padelnomics/templates/emails/supplier_enquiry.html
Normal 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> <{{ contact_email }}></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 %}
|
||||||
25
web/src/padelnomics/templates/emails/welcome.html
Normal file
25
web/src/padelnomics/templates/emails/welcome.html
Normal 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;">●</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;">●</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;">●</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 %}
|
||||||
@@ -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;">●</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;">●</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;">●</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> <{contact_email}></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",
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user