feat: add user feedback form with DB persistence and email notifications #81
@@ -163,6 +163,92 @@ class EmailService:
|
||||
logger.exception("Failed to send account invite 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,
|
||||
@@ -334,3 +420,106 @@ def _render_password_reset_html(reset_url: str) -> str:
|
||||
</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_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>"""
|
||||
|
||||
Reference in New Issue
Block a user