Files
chore/backend/utils/email_sender.py
Ryan Kegel 6a29e263aa
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m43s
feat: enhance email templates for verification, password reset, and PIN setup
2026-04-24 00:32:44 -04:00

262 lines
10 KiB
Python

from collections import defaultdict
from flask import current_app
from flask_mail import Mail, Message
import os
# ---------------------------------------------------------------------------
# Shared email template
# ---------------------------------------------------------------------------
def _build_email_html(content_html: str, frontend_url: str) -> str:
"""Wrap content in the standard Chorly branded email shell."""
logo_url = f"{frontend_url}/images/full_logo.png"
return f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f0f2ff;font-family:'Segoe UI',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0f2ff;padding:32px 16px;">
<tr>
<td align="center">
<table width="100%" style="max-width:580px;">
<!-- Logo header -->
<tr>
<td align="center" style="background-color:#ffffff;border-radius:12px 12px 0 0;padding:28px 32px 20px;">
<img src="{logo_url}" alt="Chorly" width="260" style="display:block;max-width:100%;height:auto;">
</td>
</tr>
<!-- Accent bar -->
<tr>
<td style="background:linear-gradient(90deg,#667eea 0%,#764ba2 100%);height:4px;font-size:0;line-height:0;">&nbsp;</td>
</tr>
<!-- Body content -->
<tr>
<td style="background-color:#ffffff;padding:32px;border-radius:0 0 12px 12px;">
{content_html}
</td>
</tr>
<!-- Footer -->
<tr>
<td align="center" style="padding:20px 0 8px;color:#9eaac4;font-size:0.78rem;line-height:1.6;">
Chorly &mdash; Making Chores Fun
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
def _primary_button(label: str, url: str) -> str:
return (
f'<a href="{url}" style="display:inline-block;padding:12px 28px;'
f'background-color:#667eea;color:#ffffff;font-weight:600;font-size:0.95rem;'
f'text-decoration:none;border-radius:8px;letter-spacing:0.02em;">'
f'{label}</a>'
)
def _action_button(label: str, url: str, bg: str, fg: str = '#ffffff') -> str:
return (
f'<a href="{url}" style="display:inline-block;padding:9px 20px;'
f'background-color:{bg};color:{fg};font-weight:600;font-size:0.85rem;'
f'text-decoration:none;border-radius:6px;margin:2px 4px;">'
f'{label}</a>'
)
# ---------------------------------------------------------------------------
# Email senders
# ---------------------------------------------------------------------------
def send_verification_email(to_email: str, token: str) -> None:
frontend_url = current_app.config['FRONTEND_URL']
verify_url = f"{frontend_url}/auth/verify?token={token}"
content = f"""
<h2 style="margin:0 0 12px;color:#667eea;font-size:1.3rem;">Verify your account</h2>
<p style="color:#444;margin:0 0 28px;line-height:1.6;">
Welcome to Chorly! Click the button below to confirm your email address
and activate your account.
</p>
<p style="margin:0 0 28px;text-align:center;">
{_primary_button('Verify My Account', verify_url)}
</p>
<p style="color:#888;font-size:0.82rem;margin:0;line-height:1.6;">
If you didn't create a Chorly account, you can safely ignore this email.<br>
Or copy this link into your browser: <a href="{verify_url}" style="color:#667eea;">{verify_url}</a>
</p>
"""
html_body = _build_email_html(content, frontend_url)
msg = Message(
subject="Verify your Chorly account",
recipients=[to_email],
html=html_body,
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
)
try:
if os.environ.get('DB_ENV') == 'e2e':
return
Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Verification: {verify_url}")
except Exception:
print(f"Failed to send email to {to_email}. Verification link: {verify_url}")
def send_reset_password_email(to_email: str, token: str) -> None:
frontend_url = current_app.config['FRONTEND_URL']
reset_url = f"{frontend_url}/auth/reset-password?token={token}"
content = f"""
<h2 style="margin:0 0 12px;color:#667eea;font-size:1.3rem;">Reset your password</h2>
<p style="color:#444;margin:0 0 28px;line-height:1.6;">
We received a request to reset your Chorly password. Click the button below
to choose a new one. This link expires in&nbsp;<strong>10&nbsp;minutes</strong>.
</p>
<p style="margin:0 0 28px;text-align:center;">
{_primary_button('Reset My Password', reset_url)}
</p>
<p style="color:#888;font-size:0.82rem;margin:0;line-height:1.6;">
If you didn't request a password reset, you can safely ignore this email &mdash;
your password will not change.<br>
Or copy this link into your browser: <a href="{reset_url}" style="color:#667eea;">{reset_url}</a>
</p>
"""
html_body = _build_email_html(content, frontend_url)
msg = Message(
subject="Reset your Chorly password",
recipients=[to_email],
html=html_body,
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
)
try:
if os.environ.get('DB_ENV') == 'e2e':
return
Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Reset password: {reset_url}")
except Exception:
print(f"Failed to send email to {to_email}. Reset link: {reset_url}")
def send_pin_setup_email(to_email: str, code: str) -> None:
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
content = f"""
<h2 style="margin:0 0 12px;color:#667eea;font-size:1.3rem;">Set up your Parent PIN</h2>
<p style="color:#444;margin:0 0 20px;line-height:1.6;">
To set your Parent PIN, enter the following code in the app.
It is valid for&nbsp;<strong>10&nbsp;minutes</strong>.
</p>
<div style="background:#f0f2ff;border:2px solid #667eea;border-radius:10px;
text-align:center;padding:22px 16px;margin:0 0 24px;">
<span style="font-size:2.4rem;font-weight:700;letter-spacing:0.35em;
color:#667eea;font-family:'Courier New',monospace;">{code}</span>
</div>
<p style="color:#888;font-size:0.82rem;margin:0;line-height:1.6;">
If you did not request this, you can safely ignore this email.
</p>
"""
html_body = _build_email_html(content, frontend_url)
msg = Message(
subject="Set up your Chorly Parent PIN",
recipients=[to_email],
html=html_body,
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
)
try:
if os.environ.get('DB_ENV') == 'e2e':
return
Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Parent PIN setup code: {code}")
except Exception:
print(f"Failed to send email to {to_email}. Parent PIN setup code: {code}")
def send_digest_email(to_email: str, items: list, unsubscribe_token: str) -> None:
"""
Send the nightly digest email listing pending chore/reward items.
Each item in `items` is a dict with keys:
child_name, entity_name, entity_type, view_url, approve_url, deny_url
Items are grouped by child_name in the email.
"""
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
unsubscribe_url = f"{frontend_url}/api/digest-unsubscribe/{unsubscribe_token}"
by_child: dict = defaultdict(list)
for item in items:
by_child[item['child_name']].append(item)
child_sections = ''
for child_name, child_items in by_child.items():
rows = ''
for item in child_items:
entity_label = 'Chore' if item['entity_type'] == 'chore' else 'Reward'
rows += f"""
<tr>
<td style="padding:10px 8px;border-bottom:1px solid #eef0fb;vertical-align:middle;">
<span style="font-weight:600;color:#333;">{item['entity_name']}</span>
<span style="display:inline-block;margin-left:8px;padding:2px 8px;
background:#f0f2ff;color:#667eea;border-radius:20px;
font-size:0.75rem;font-weight:600;">{entity_label}</span>
</td>
<td style="padding:10px 8px;border-bottom:1px solid #eef0fb;text-align:right;white-space:nowrap;">
{_action_button('View', item['view_url'], '#667eea')}
{_action_button('Approve', item['approve_url'], '#22c55e')}
{_action_button('Deny', item['deny_url'], '#ef4444')}
</td>
</tr>"""
child_sections += f"""
<div style="margin-bottom:28px;">
<div style="font-size:0.7rem;font-weight:700;letter-spacing:0.08em;
text-transform:uppercase;color:#9eaac4;margin-bottom:6px;">{child_name}</div>
<table width="100%" cellpadding="0" cellspacing="0"
style="border-collapse:collapse;font-size:0.9rem;
border:1px solid #eef0fb;border-radius:8px;overflow:hidden;">
<tbody>{rows}</tbody>
</table>
</div>"""
content = f"""
<h2 style="margin:0 0 6px;color:#667eea;font-size:1.3rem;">Daily Summary</h2>
<p style="color:#444;margin:0 0 24px;line-height:1.6;">
You have pending items that need your attention:
</p>
{child_sections}
<p style="margin:24px 0 0;text-align:center;font-size:0.78rem;color:#b0b8cc;">
<a href="{unsubscribe_url}" style="color:#b0b8cc;text-decoration:underline;">
Unsubscribe from daily digest
</a>
</p>
"""
html_body = _build_email_html(content, frontend_url)
msg = Message(
subject='Chorly \u2014 Daily Summary',
recipients=[to_email],
html=html_body,
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local'),
)
try:
if os.environ.get('DB_ENV') == 'e2e':
return
Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Daily digest sent ({len(items)} items)")
except Exception:
print(f"Failed to send digest email to {to_email}")