Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m43s
262 lines
10 KiB
Python
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;"> </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:
|
|
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 <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(
|
|
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 <strong>10 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}") |