feat: enhance email templates for verification, password reset, and PIN setup
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m43s

This commit is contained in:
2026-04-24 00:32:44 -04:00
parent 89de4fd869
commit 6a29e263aa

View File

@@ -1,62 +1,186 @@
from collections import defaultdict
from flask import current_app from flask import current_app
from flask_mail import Mail, Message from flask_mail import Mail, Message
import os 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: def send_verification_email(to_email: str, token: str) -> None:
verify_url = f"{current_app.config['FRONTEND_URL']}/auth/verify?token={token}" frontend_url = current_app.config['FRONTEND_URL']
html_body = f'Click <a href="{verify_url}">here</a> to verify your account.' 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( msg = Message(
subject="Verify your account", subject="Verify your Chorly account",
recipients=[to_email], recipients=[to_email],
html=html_body, html=html_body,
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local') sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
) )
try: try:
if os.environ.get('DB_ENV') =='e2e': if os.environ.get('DB_ENV') == 'e2e':
return # Skip sending emails during e2e tests to avoid cluttering inboxes and logs return
Mail(current_app).send(msg) Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Verification: {verify_url}") print(f"[EMAIL to {to_email}] Verification: {verify_url}")
except Exception: except Exception:
print(f"Failed to send email to {to_email}. Verification link: {verify_url}") print(f"Failed to send email to {to_email}. Verification link: {verify_url}")
def send_reset_password_email(to_email: str, token: str) -> None: def send_reset_password_email(to_email: str, token: str) -> None:
reset_url = f"{current_app.config['FRONTEND_URL']}/auth/reset-password?token={token}" frontend_url = current_app.config['FRONTEND_URL']
html_body = f'Click <a href="{reset_url}">here</a> to reset your password.' 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( msg = Message(
subject="Reset your password", subject="Reset your Chorly password",
recipients=[to_email], recipients=[to_email],
html=html_body, html=html_body,
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local') sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
) )
try: try:
if os.environ.get('DB_ENV') =='e2e': if os.environ.get('DB_ENV') == 'e2e':
return # Skip sending emails during e2e tests to avoid cluttering inboxes and logs return
Mail(current_app).send(msg) Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Reset password: {reset_url}") print(f"[EMAIL to {to_email}] Reset password: {reset_url}")
except Exception: except Exception:
print(f"Failed to send email to {to_email}. Reset link: {reset_url}") print(f"Failed to send email to {to_email}. Reset link: {reset_url}")
def send_pin_setup_email(to_email: str, code: str) -> None: def send_pin_setup_email(to_email: str, code: str) -> None:
html_body = f""" frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
<div style='font-family:sans-serif;'>
<h2>Set up your Parent PIN</h2> content = f"""
<p>To set your Parent PIN, enter the following code in the app:</p> <h2 style="margin:0 0 12px;color:#667eea;font-size:1.3rem;">Set up your Parent PIN</h2>
<div style='font-size:2rem; font-weight:bold; letter-spacing:0.2em; margin:1.5rem 0;'>{code}</div> <p style="color:#444;margin:0 0 20px;line-height:1.6;">
<p>This code is valid for 10 minutes.</p> To set your Parent PIN, enter the following code in the app.
<p>If you did not request this, you can ignore this email.</p> It is valid for&nbsp;<strong>10&nbsp;minutes</strong>.
<hr> </p>
<div style='color:#888;font-size:0.95rem;'>Reward App</div> <div style="background:#f0f2ff;border:2px solid #667eea;border-radius:10px;
</div> 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( msg = Message(
subject="Set up your Parent PIN", subject="Set up your Chorly Parent PIN",
recipients=[to_email], recipients=[to_email],
html=html_body, html=html_body,
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local') sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
) )
try: try:
if os.environ.get('DB_ENV') =='e2e': if os.environ.get('DB_ENV') == 'e2e':
return # Skip sending emails during e2e tests to avoid cluttering inboxes and logs return
Mail(current_app).send(msg) Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Parent PIN setup code: {code}") print(f"[EMAIL to {to_email}] Parent PIN setup code: {code}")
except Exception: except Exception:
@@ -74,8 +198,6 @@ def send_digest_email(to_email: str, items: list, unsubscribe_token: str) -> Non
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173') frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
unsubscribe_url = f"{frontend_url}/api/digest-unsubscribe/{unsubscribe_token}" unsubscribe_url = f"{frontend_url}/api/digest-unsubscribe/{unsubscribe_token}"
# Group items by child
from collections import defaultdict
by_child: dict = defaultdict(list) by_child: dict = defaultdict(list)
for item in items: for item in items:
by_child[item['child_name']].append(item) by_child[item['child_name']].append(item)
@@ -87,48 +209,46 @@ def send_digest_email(to_email: str, items: list, unsubscribe_token: str) -> Non
entity_label = 'Chore' if item['entity_type'] == 'chore' else 'Reward' entity_label = 'Chore' if item['entity_type'] == 'chore' else 'Reward'
rows += f""" rows += f"""
<tr> <tr>
<td style="padding:8px 4px;border-bottom:1px solid #eee;"> <td style="padding:10px 8px;border-bottom:1px solid #eef0fb;vertical-align:middle;">
<span style="font-weight:500;">{item['entity_name']}</span> <span style="font-weight:600;color:#333;">{item['entity_name']}</span>
<span style="color:#888;font-size:0.85em;margin-left:6px;">{entity_label}</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>
<td style="padding:8px 4px;border-bottom:1px solid #eee;text-align:center;"> <td style="padding:10px 8px;border-bottom:1px solid #eef0fb;text-align:right;white-space:nowrap;">
<a href="{item['view_url']}" {_action_button('View', item['view_url'], '#667eea')}
style="color:#0066cc;text-decoration:underline;margin-right:8px;">View</a> {_action_button('Approve', item['approve_url'], '#22c55e')}
<a href="{item['approve_url']}" {_action_button('Deny', item['deny_url'], '#ef4444')}
style="color:#22863a;font-weight:bold;text-decoration:underline;margin-right:8px;">Approve</a>
<a href="{item['deny_url']}"
style="color:#cb2431;font-weight:bold;text-decoration:underline;">Deny</a>
</td> </td>
</tr>""" </tr>"""
child_sections += f""" child_sections += f"""
<div style="margin-bottom:24px;"> <div style="margin-bottom:28px;">
<h3 style="color:#333;margin:0 0 8px 0;font-size:1rem;">{child_name}</h3> <div style="font-size:0.7rem;font-weight:700;letter-spacing:0.08em;
<table style="width:100%;border-collapse:collapse;font-size:0.92rem;"> 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> <tbody>{rows}</tbody>
</table> </table>
</div>""" </div>"""
html_body = f""" content = f"""
<div style="font-family:sans-serif;max-width:600px;margin:0 auto;"> <h2 style="margin:0 0 6px;color:#667eea;font-size:1.3rem;">Daily Summary</h2>
<div style="background:#4a90e2;color:#fff;padding:18px 24px;border-radius:6px 6px 0 0;"> <p style="color:#444;margin:0 0 24px;line-height:1.6;">
<h2 style="margin:0;font-size:1.2rem;">Reward App &mdash; Daily Summary</h2> You have pending items that need your attention:
</div> </p>
<div style="background:#fff;padding:24px;border:1px solid #ddd;border-top:none;border-radius:0 0 6px 6px;"> {child_sections}
<p style="color:#555;margin:0 0 20px 0;"> <p style="margin:24px 0 0;text-align:center;font-size:0.78rem;color:#b0b8cc;">
You have pending items that need your attention: <a href="{unsubscribe_url}" style="color:#b0b8cc;text-decoration:underline;">
</p> Unsubscribe from daily digest
{child_sections} </a>
</div> </p>
<div style="color:#aaa;font-size:0.8rem;padding:12px 0;text-align:center;">
Reward App &bull;
<a href="{unsubscribe_url}" style="color:#aaa;">Unsubscribe from daily digest</a>
</div>
</div>
""" """
html_body = _build_email_html(content, frontend_url)
msg = Message( msg = Message(
subject='Reward App — Daily Summary', subject='Chorly \u2014 Daily Summary',
recipients=[to_email], recipients=[to_email],
html=html_body, html=html_body,
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local'), sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local'),