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
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m43s
This commit is contained in:
@@ -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;"> </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 — 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 <strong>10 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 —
|
||||||
|
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 <strong>10 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 — 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 •
|
|
||||||
<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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user