* chore: update Google Fonts to Bricolage Grotesque, IBM Plex Sans, JetBrains Mono Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: update Tailwind config to Slate & Ice theme colors and fonts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: update CSS variables and glass-card utilities for Slate & Ice theme - Replace all color variables with Slate & Ice palette - Add glass system vars (--glass-bg, --glass-blur, --shadow-float) - Replace legacy glass-card with new variable-driven glass classes - Add breatheGlow, bellWobble, slideDown, fadeInRight keyframes - Update font references to IBM Plex Sans and Bricolage Grotesque Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: recolor BrandLogo to cyan gradient, split BrandWordmark for gradient Flow text Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: update TopBar with glassmorphism backdrop and cyan accent styling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: update Sidebar with glassmorphism backdrop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add ambient atmosphere gradient orbs behind app shell Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: update QuickStats and SessionsPanel with glass-card styling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add WeeklyCalendar, QuickActions, OpenSessions, RecentActivity dashboard components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: redesign dashboard layout with calendar, open sessions, and glass-card panels New layout: greeting → calendar+actions → sessions+stats → activity Replaces old QuickStats and SessionsPanel with new dashboard components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: replace remaining purple hex references with ice-cyan accent Sweep of hardcoded purple hex values (#818cf8, #6366f1) replaced with new cyan accent (#06b6d4) in QuickActions, RecentActivity, QuickLaunch, and SVG brand assets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update CLAUDE.md branding and design system for Slate & Ice Modern Updated Last Updated date, branding section (fonts, colors, glass utilities, atmosphere orbs), component styling rules, and Design System section to reflect the new ice-cyan glassmorphism theme. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add Slate & Ice Modern design doc and implementation plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: redesign login page with Slate & Ice Modern design system Apply glassmorphism styling, atmosphere orbs, branded wordmark, and consistent design tokens to match the updated app shell aesthetic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: raise TopBar z-index so profile dropdown renders above main content Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add AI assistant with in-session copilot and standalone chat with RAG Implements three-phase AI assistant feature: - Phase 0: RAG infrastructure with pgvector embeddings, Voyage AI integration, tree chunking service, and semantic search over team's flow library - Phase 1: In-session copilot panel during flow navigation with contextual AI help, current step awareness, and suggested related flows - Phase 2: Standalone AI chat page with persistent conversation history, pin/delete, and configurable retention policies (account-level) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add account management, email verification, AI fixes, and user guides - Profile settings, account transfer, delete/leave account flows - Email verification with JWT tokens and Resend integration - AI assistant/copilot fixes: markdown rendering, shared RAG helpers, token tracking, input refocus, model_validate usage - User guides hub + detail pages with 13 topic guides - Sidebar and top bar navigation for guides Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent stale chunk errors after deployments - Set Cache-Control no-cache on index.html in nginx so browsers always fetch fresh chunk references after a deploy - Auto-reload on chunk load failures (stale deploy detection) with loop prevention via sessionStorage - Show friendly "App Updated" message if auto-reload doesn't resolve it Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add email verification toggle to admin settings Adds platform-level toggle to enable/disable email verification. When disabled, the verification banner is hidden and the send endpoint returns 403. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
591 lines
22 KiB
Python
591 lines
22 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_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
|
|
|
|
|
|
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>"""
|