Revoke-and-recreate flow for both invite systems with email delivery via Resend API. Includes account invite email template and audit logging. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
192 lines
7.2 KiB
Python
192 lines
7.2 KiB
Python
import logging
|
|
from app.core.config import settings
|
|
|
|
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_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
|
|
|
|
|
|
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>"""
|