Files
resolutionflow/backend/app/core/email.py
Michael Chihlas f1be3abcc5
Some checks failed
CI / e2e (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled
feat: self-serve signup Phase 2 (frontend cutover) (#162)
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-07 18:42:20 +00:00

1042 lines
41 KiB
Python

import logging
from typing import TYPE_CHECKING
from app.core.config import settings
if TYPE_CHECKING:
from app.models.sales_lead import SalesLead
logger = logging.getLogger(__name__)
class EmailService:
"""Best-effort email delivery via Resend. Never raises on failure."""
@staticmethod
async def send_invite_email(
to_email: str,
code: str,
plan: str,
trial_days: int | None = None,
signup_url: str = "https://resolutionflow.com/register",
) -> bool:
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
resend.api_key = settings.RESEND_API_KEY
plan_label = plan.capitalize()
trial_text = f" with a {trial_days}-day free trial" if trial_days else ""
subject = f"You're invited to ResolutionFlow ({plan_label} plan{trial_text})"
html = _render_invite_html(
code=code,
plan_label=plan_label,
trial_days=trial_days,
signup_url=signup_url,
)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
}
)
logger.info("Invite email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send invite email to %s", to_email)
return False
@staticmethod
async def send_password_reset_email(
to_email: str,
reset_url: str,
) -> bool:
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
resend.api_key = settings.RESEND_API_KEY
subject = "Reset Your Password — ResolutionFlow"
html = _render_password_reset_html(reset_url=reset_url)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
}
)
logger.info("Password reset email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send password reset email to %s", to_email)
return False
@staticmethod
async def send_welcome_email(
to_email: str,
temp_password: str,
login_url: str = "https://resolutionflow.com/login",
) -> bool:
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
resend.api_key = settings.RESEND_API_KEY
subject = "Welcome to ResolutionFlow — Your Account Is Ready"
html = _render_welcome_html(
temp_password=temp_password,
login_url=login_url,
)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
}
)
logger.info("Welcome email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send welcome email to %s", to_email)
return False
@staticmethod
async def send_account_invite_email(
to_email: str,
code: str,
account_name: str,
role: str,
signup_url: str = "https://resolutionflow.com/register",
) -> bool:
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
resend.api_key = settings.RESEND_API_KEY
role_label = role.capitalize()
subject = f"You've been invited to join {account_name} on ResolutionFlow"
html = _render_account_invite_html(
code=code,
account_name=account_name,
role_label=role_label,
signup_url=signup_url,
)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
}
)
logger.info("Account invite email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send account invite email to %s", to_email)
return False
@staticmethod
async def send_email_verification_email(
to_email: str,
verification_url: str,
) -> bool:
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
resend.api_key = settings.RESEND_API_KEY
subject = "Verify Your Email — ResolutionFlow"
html = _render_email_verification_html(verification_url=verification_url)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
}
)
logger.info("Verification email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send verification email to %s", to_email)
return False
@staticmethod
async def send_feedback_email(
to_email: str,
reply_to_email: str,
feedback_type: str,
message: str,
user_email: str,
account_name: str | None = None,
account_code: str | None = None,
) -> bool:
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
from datetime import datetime, timezone
resend.api_key = settings.RESEND_API_KEY
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
code_suffix = f"{account_code}" if account_code else ""
subject = f"[ResolutionFlow Feedback] {feedback_type}{date_str}{code_suffix}"
html = _render_feedback_html(
feedback_type=feedback_type,
message=message,
user_email=user_email,
account_name=account_name,
account_code=account_code,
)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"reply_to": reply_to_email,
"subject": subject,
"html": html,
}
)
logger.info("Feedback email sent from %s (type: %s)", user_email, feedback_type)
return True
except Exception:
logger.exception("Failed to send feedback email from %s", user_email)
return False
@staticmethod
async def send_feedback_confirmation_email(
to_email: str,
feedback_type: str,
message_preview: str,
) -> bool:
"""Send a thank-you confirmation to the feedback submitter. Fire-and-forget."""
if not settings.email_enabled:
logger.warning("Confirmation email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
resend.api_key = settings.RESEND_API_KEY
subject = "Thanks for your feedback — ResolutionFlow"
html = _render_feedback_confirmation_html(
feedback_type=feedback_type,
message_preview=message_preview,
)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
}
)
logger.info("Feedback confirmation email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send feedback confirmation to %s", to_email)
return False
@staticmethod
async def send_survey_notification_email(
to_email: str,
respondent_name: str | None,
responses: dict,
) -> bool:
"""Send survey response notification. Fire-and-forget."""
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
import html as html_mod
from datetime import datetime, timezone
resend.api_key = settings.RESEND_API_KEY
name_display = html_mod.escape(respondent_name) if respondent_name else "Anonymous"
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
subject = f"[FlowPilot Survey] New Response from {name_display}{date_str}"
rows_html = ""
for q_id, answer in responses.items():
safe_id = html_mod.escape(str(q_id))
if isinstance(answer, list):
answer_display = "<br>".join(f"&bull; {html_mod.escape(str(item))}" for item in answer)
else:
answer_display = html_mod.escape(str(answer))
rows_html += f"""
<tr>
<td style="padding: 10px 14px; border-bottom: 1px solid rgba(255,255,255,0.06); color: #06b6d4; font-weight: 600; vertical-align: top; width: 120px; font-size: 13px;">{safe_id}</td>
<td style="padding: 10px 14px; border-bottom: 1px solid rgba(255,255,255,0.06); color: #e4e4e7; font-size: 14px; white-space: pre-wrap;">{answer_display}</td>
</tr>"""
email_html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0; padding:0; background-color:#101114; font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#101114; padding:40px 20px;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#14161a; border-radius:16px; border:1px solid rgba(255,255,255,0.06);">
<tr><td style="padding:32px 32px 20px;">
<h1 style="margin:0 0 4px; font-size:20px; color:#f8fafc;">FlowPilot Survey Response</h1>
<p style="margin:0; font-size:13px; color:#5a6170;">From: {name_display} &bull; {date_str}</p>
</td></tr>
<tr><td style="padding:0 32px 32px;">
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid rgba(255,255,255,0.06); border-radius:8px; overflow:hidden;">
{rows_html}
</table>
</td></tr>
<tr><td style="padding:0 32px 24px;">
<p style="margin:0; font-size:12px; color:#5a6170;">This response has been saved to the survey_responses table.</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
resend.Emails.send({
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": email_html,
})
logger.info("Survey notification sent for respondent: %s", name_display)
return True
except Exception:
logger.exception("Failed to send survey notification email")
return False
@staticmethod
async def send_survey_copy_email(
to_email: str,
respondent_name: str | None,
formatted_responses: str,
) -> bool:
"""Send a copy of survey responses to the respondent."""
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
import html as html_mod
resend.api_key = settings.RESEND_API_KEY
safe_name = html_mod.escape(respondent_name or "there")
safe_responses = html_mod.escape(formatted_responses).replace("\n", "<br>")
subject = "Your FlowPilot Survey Responses"
email_html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">Your Survey Responses</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
Hi {safe_name}, here's a copy of your FlowPilot survey responses for your records.
</p>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<div style="background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.06);border-radius:12px;padding:24px;">
<p style="margin:0;color:#e0e0e0;font-size:13px;line-height:1.8;">{safe_responses}</p>
</div>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
Thank you for your contribution to FlowPilot research.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
resend.Emails.send({
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": email_html,
})
logger.info("Survey copy email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send survey copy email to %s", to_email)
return False
@staticmethod
async def send_beta_signup_notification(
signup_email: str,
notify_email: str = "beta@resolutionflow.com",
) -> bool:
"""Notify beta@resolutionflow.com about a new beta signup. Fire-and-forget."""
if not settings.email_enabled:
logger.warning("Beta signup email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
import html as html_mod
from datetime import datetime, timezone
resend.api_key = settings.RESEND_API_KEY
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
safe_email = html_mod.escape(signup_email)
subject = f"[ResolutionFlow Beta] New signup — {safe_email}"
email_html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">New Beta Signup</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
A new user has requested beta access:
</p>
</td></tr>
<tr><td style="padding:0 40px 24px;text-align:center;">
<div style="background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.06);border-radius:12px;padding:20px;">
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Email</p>
<p style="margin:0;color:#22d3ee;font-size:18px;font-weight:600;">{safe_email}</p>
</div>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
Submitted at {date_str}
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
resend.Emails.send({
"from": settings.FROM_EMAIL,
"to": [notify_email],
"reply_to": signup_email,
"subject": subject,
"html": email_html,
})
logger.info("Beta signup notification sent for %s", signup_email)
return True
except Exception:
logger.exception("Failed to send beta signup notification for %s", signup_email)
return False
@staticmethod
async def send_sales_lead_notification(
to_email: str,
lead: "SalesLead",
) -> bool:
"""Notify the sales recipient about a new Talk-to-Sales submission.
Fire-and-forget. Returns False (and logs) on any failure; never raises.
"""
if not settings.email_enabled:
logger.warning(
"Sales lead email not sent — RESEND_API_KEY not configured (lead %s)",
lead.id,
)
return False
try:
import resend
import html as html_mod
from datetime import datetime, timezone
resend.api_key = settings.RESEND_API_KEY
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
safe_email = html_mod.escape(lead.email)
safe_name = html_mod.escape(lead.name)
safe_company = html_mod.escape(lead.company)
safe_team_size = html_mod.escape(lead.team_size or "")
safe_source = html_mod.escape(lead.source)
safe_message = html_mod.escape(lead.message or "(no message)")
subject = f"[ResolutionFlow Sales] New lead — {safe_company} ({safe_email})"
email_html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">New Sales Lead</p>
</td></tr>
<tr><td style="padding:0 40px 16px;">
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
Source: <strong style="color:#f8fafc;">{safe_source}</strong>
</p>
</td></tr>
<tr><td style="padding:0 40px 16px;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.06);border-radius:12px;">
<tr><td style="padding:16px;">
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Name</p>
<p style="margin:0 0 12px;color:#f8fafc;font-size:16px;font-weight:600;">{safe_name}</p>
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Email</p>
<p style="margin:0 0 12px;color:#22d3ee;font-size:16px;font-weight:600;">{safe_email}</p>
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Company</p>
<p style="margin:0 0 12px;color:#f8fafc;font-size:16px;font-weight:600;">{safe_company}</p>
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Team Size</p>
<p style="margin:0;color:#f8fafc;font-size:16px;font-weight:600;">{safe_team_size}</p>
</td></tr>
</table>
</td></tr>
<tr><td style="padding:0 40px 16px;">
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Message</p>
<p style="margin:0;color:#8891a0;font-size:14px;line-height:1.6;white-space:pre-wrap;">{safe_message}</p>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
Submitted at {date_str} · Lead ID: {lead.id}
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
resend.Emails.send({
"from": settings.FROM_EMAIL,
"to": [to_email],
"reply_to": lead.email,
"subject": subject,
"html": email_html,
})
logger.info("Sales lead notification sent for %s (lead %s)", lead.email, lead.id)
return True
except Exception:
logger.exception(
"Failed to send sales lead notification for %s (lead %s)",
lead.email,
lead.id,
)
return False
@staticmethod
async def send_notification_email(
to_email: str,
title: str,
body: str,
link_url: str | None = None,
) -> bool:
"""Send a notification email. Fire-and-forget."""
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
resend.api_key = settings.RESEND_API_KEY
subject = f"[ResolutionFlow] {title}"
html = _render_notification_html(
title=title,
body=body,
link_url=link_url,
)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
}
)
logger.info("Notification email sent to %s: %s", to_email, title)
return True
except Exception:
logger.exception("Failed to send notification email to %s", to_email)
return False
@staticmethod
async def send_survey_invite_email(
to_email: str,
recipient_name: str,
survey_url: str,
) -> bool:
"""Send survey invite email."""
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
import html as html_mod
resend.api_key = settings.RESEND_API_KEY
safe_name = html_mod.escape(recipient_name)
subject = "FlowPilot Survey — Your expertise matters"
email_html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">FlowPilot Research</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
Hi {safe_name},
</p>
<p style="margin:12px 0 0;color:#8891a0;font-size:16px;line-height:1.6;">
We're building an AI assistant for MSP engineers and would love your input. This 5-minute survey will help shape how FlowPilot thinks about troubleshooting.
</p>
</td></tr>
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{survey_url}" style="display:inline-block;background:linear-gradient(135deg,#06b6d4,#22d3ee);color:#101114;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:10px;">
Take the Survey
</a>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
Your responses are confidential. Takes about 5 minutes.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
resend.Emails.send({
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": email_html,
})
logger.info("Survey invite email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send survey invite email to %s", to_email)
return False
def _render_invite_html(
code: str,
plan_label: str,
trial_days: int | None,
signup_url: str,
) -> str:
trial_section = ""
if trial_days:
trial_section = f"""
<tr><td style="padding:0 40px 20px;">
<p style="margin:0;color:#a0a0a0;font-size:14px;">
Your <strong style="color:#fff;">{trial_days}-day free trial</strong> starts when you register.
After your trial ends, your account will revert to the Free plan.
</p>
</td></tr>"""
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Decision Tree Platform for MSP Professionals</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
You've been invited to join ResolutionFlow on the <strong style="color:#fff;">{plan_label}</strong> plan.
</p>
</td></tr>
<tr><td style="padding:0 40px 24px;text-align:center;">
<div style="background:#000;border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:20px;">
<p style="margin:0 0 4px;color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Your Invite Code</p>
<p style="margin:0;color:#fff;font-size:28px;font-weight:700;letter-spacing:4px;">{code}</p>
</div>
</td></tr>
{trial_section}
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{signup_url}" style="display:inline-block;background:#fff;color:#000;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:8px;">
Create Your Account
</a>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
Enter the code above during registration, or click the button to get started.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
def _render_account_invite_html(
code: str,
account_name: str,
role_label: str,
signup_url: str,
) -> str:
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Decision Tree Platform for MSP Professionals</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
You've been invited to join <strong style="color:#fff;">{account_name}</strong> as an <strong style="color:#fff;">{role_label}</strong>.
</p>
</td></tr>
<tr><td style="padding:0 40px 24px;text-align:center;">
<div style="background:#000;border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:20px;">
<p style="margin:0 0 4px;color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Your Invite Code</p>
<p style="margin:0;color:#fff;font-size:28px;font-weight:700;letter-spacing:4px;">{code}</p>
</div>
</td></tr>
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{signup_url}" style="display:inline-block;background:#fff;color:#000;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:8px;">
Create Your Account
</a>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
Enter the code above during registration, or click the button to get started.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
def _render_welcome_html(
temp_password: str,
login_url: str,
) -> str:
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Decision Tree Platform for MSP Professionals</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
Your account has been created. Use the temporary password below to sign in.
You will be asked to change it on first login.
</p>
</td></tr>
<tr><td style="padding:0 40px 24px;text-align:center;">
<div style="background:#000;border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:20px;">
<p style="margin:0 0 4px;color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Temporary Password</p>
<p style="margin:0;color:#fff;font-size:20px;font-weight:700;letter-spacing:2px;font-family:monospace;">{temp_password}</p>
</div>
</td></tr>
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{login_url}" style="display:inline-block;background:#fff;color:#000;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:8px;">
Sign In
</a>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
For security, please change your password immediately after signing in.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
def _render_password_reset_html(reset_url: str) -> str:
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Decision Tree Platform for MSP Professionals</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
We received a request to reset your password. Click the button below to choose a new password.
This link expires in 30 minutes.
</p>
</td></tr>
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{reset_url}" style="display:inline-block;background:#fff;color:#000;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:8px;">
Reset Your Password
</a>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
If you didn't request this, you can safely ignore this email. Your password will not be changed.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
def _render_feedback_html(
feedback_type: str,
message: str,
user_email: str,
account_name: str | None,
account_code: str | None,
) -> str:
from datetime import datetime, timezone
import html
date_str = datetime.now(timezone.utc).strftime("%B %d, %Y")
safe_message = html.escape(message).replace("\n", "<br>")
account_line = ""
if account_name and account_code:
account_line = f"""
<tr><td style="padding:0 40px 8px;">
<p style="margin:0;color:#a0a0a0;font-size:14px;">
<strong style="color:#e0e0e0;">Account:</strong> {html.escape(account_name)} ({html.escape(account_code)})
</p>
</td></tr>"""
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow Feedback</h1>
</td></tr>
<tr><td style="padding:0 40px 8px;">
<p style="margin:0;color:#a0a0a0;font-size:14px;">
<strong style="color:#e0e0e0;">Type:</strong> {html.escape(feedback_type)}
</p>
</td></tr>
<tr><td style="padding:0 40px 8px;">
<p style="margin:0;color:#a0a0a0;font-size:14px;">
<strong style="color:#e0e0e0;">From:</strong> {html.escape(user_email)}
</p>
</td></tr>
{account_line}
<tr><td style="padding:0 40px 8px;">
<p style="margin:0;color:#a0a0a0;font-size:14px;">
<strong style="color:#e0e0e0;">Date:</strong> {date_str}
</p>
</td></tr>
<tr><td style="padding:16px 40px 0;">
<div style="border-top:1px solid rgba(255,255,255,0.06);padding-top:16px;">
<p style="margin:0;color:#e0e0e0;font-size:15px;line-height:1.7;">{safe_message}</p>
</div>
</td></tr>
<tr><td style="padding:24px 40px 32px;">
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
Reply directly to this email to respond to the user.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
def _render_email_verification_html(verification_url: str) -> str:
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Decision Tree Platform for MSP Professionals</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
Please verify your email address by clicking the button below. This link expires in 24 hours.
</p>
</td></tr>
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{verification_url}" style="display:inline-block;background:#fff;color:#000;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:8px;">
Verify Email
</a>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
If you didn't create an account, you can safely ignore this email.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
def _render_feedback_confirmation_html(
feedback_type: str,
message_preview: str,
) -> str:
import html
safe_preview = html.escape(message_preview)
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Thanks for your feedback!</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
We've received your <strong style="color:#fff;">{html.escape(feedback_type)}</strong> and our team will review it shortly.
</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<div style="background:#000;border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:16px 20px;">
<p style="margin:0 0 4px;color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Your feedback</p>
<p style="margin:0;color:#e0e0e0;font-size:14px;line-height:1.5;font-style:italic;">"{safe_preview}"</p>
</div>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
If we need more details, we'll reach out to you directly.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
def _render_notification_html(
title: str,
body: str,
link_url: str | None = None,
) -> str:
import html as html_mod
safe_title = html_mod.escape(title)
safe_body = html_mod.escape(body)
link_section = ""
if link_url:
link_section = f"""
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{link_url}" style="display:inline-block;background:linear-gradient(135deg,#06b6d4,#22d3ee);color:#101114;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:10px;">
View in ResolutionFlow
</a>
</td></tr>"""
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
</td></tr>
<tr><td style="padding:0 40px 12px;">
<h2 style="margin:0;color:#f8fafc;font-size:18px;font-weight:600;">{safe_title}</h2>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">{safe_body}</p>
</td></tr>
{link_section}
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
&mdash; ResolutionFlow
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""