Files
gw/backend/app/services/email.py
T
ponzischeme89 6d44e05de4 v1
2026-04-18 07:23:55 +12:00

167 lines
7.0 KiB
Python

"""
Email sending service.
In development (SMTP_HOST unset or EMAIL_BACKEND=console), codes are printed to
stdout instead of being sent. In production set:
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, EMAIL_FROM
"""
import asyncio
import smtplib
import ssl
import logging
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from functools import partial
from app.config import settings
logger = logging.getLogger(__name__)
def _send_smtp_blocking(to_address: str, subject: str, html_body: str, text_body: str) -> None:
context = ssl.create_default_context()
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
if settings.SMTP_USE_TLS:
server.starttls(context=context)
if settings.SMTP_USER and settings.SMTP_PASSWORD:
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = settings.EMAIL_FROM
msg["To"] = to_address
msg.attach(MIMEText(text_body, "plain"))
msg.attach(MIMEText(html_body, "html"))
server.sendmail(settings.EMAIL_FROM, to_address, msg.as_string())
async def send_email(to_address: str, subject: str, html_body: str, text_body: str) -> None:
if settings.EMAIL_BACKEND == "console" or not settings.SMTP_HOST:
logger.info(
"\n%s\nTO: %s\nSUBJECT: %s\n%s\n%s",
"=" * 60,
to_address,
subject,
text_body,
"=" * 60,
)
print(f"\n{'='*60}\nEMAIL TO: {to_address}\nSUBJECT: {subject}\n{text_body}\n{'='*60}\n")
return
loop = asyncio.get_event_loop()
fn = partial(_send_smtp_blocking, to_address, subject, html_body, text_body)
await loop.run_in_executor(None, fn)
# ── Template helpers ───────────────────────────────────────────────────────────
def _base_html(content: str) -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {{ font-family: 'Readex Pro', Arial, sans-serif; background: #FBFBFB; margin: 0; padding: 0; }}
.container {{ max-width: 560px; margin: 40px auto; background: #fff; border-radius: 16px;
overflow: hidden; box-shadow: 0 4px 24px rgba(0,40,66,.10); }}
.header {{ background: #002842; padding: 32px 40px; text-align: center; }}
.header h1 {{ color: #FFD100; font-family: 'Fredoka One', Arial, sans-serif;
font-size: 28px; margin: 0; letter-spacing: .5px; }}
.header p {{ color: #E5EEFF; margin: 6px 0 0; font-size: 14px; }}
.body {{ padding: 36px 40px; color: #2E3031; }}
.body p {{ line-height: 1.6; margin: 0 0 16px; }}
.code-box {{ background: #E5EEFF; border-radius: 12px; padding: 20px;
text-align: center; margin: 24px 0; }}
.code {{ font-size: 36px; font-weight: 700; letter-spacing: 10px; color: #002842;
font-family: 'Fredoka One', monospace; }}
.footer {{ background: #F4F6FB; padding: 20px 40px; text-align: center;
font-size: 12px; color: #888; border-top: 1px solid #E5EEFF; }}
.expiry {{ color: #888; font-size: 13px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🐾 Goodwalk</h1>
<p>Auckland's favourite dog walking service</p>
</div>
{content}
<div class="footer">
<p>Goodwalk &mdash; Auckland, New Zealand<br>
<a href="mailto:info@goodwalk.co.nz" style="color:#FFD100;">info@goodwalk.co.nz</a>
</p>
<p>If you didn't request this email, you can safely ignore it.</p>
</div>
</div>
</body>
</html>"""
async def send_claim_code(to_address: str, first_name: str, code: str) -> None:
subject = "Claim your Goodwalk Members Account"
html_body = _base_html(f"""
<div class="body">
<p>Hi {first_name}!</p>
<p>Welcome to the Goodwalk Members Area. Use the code below to claim your account.
It expires in <strong>15 minutes</strong>.</p>
<div class="code-box">
<div class="code">{code}</div>
</div>
<p class="expiry">This code is valid for 15 minutes and can only be used once.</p>
<p>If you didn't request this, please contact us at
<a href="mailto:info@goodwalk.co.nz">info@goodwalk.co.nz</a>.</p>
</div>""")
text_body = (
f"Hi {first_name},\n\n"
f"Your Goodwalk account claim code is: {code}\n\n"
"This code expires in 15 minutes.\n\n"
"If you didn't request this, please ignore this email."
)
await send_email(to_address, subject, html_body, text_body)
async def send_login_2fa(to_address: str, first_name: str, code: str) -> None:
subject = "Your Goodwalk login code"
html_body = _base_html(f"""
<div class="body">
<p>Hi {first_name}!</p>
<p>Here's your one-time login code for the Goodwalk Members Area.
It expires in <strong>10 minutes</strong>.</p>
<div class="code-box">
<div class="code">{code}</div>
</div>
<p class="expiry">This code is valid for 10 minutes and can only be used once.</p>
<p>If you didn't try to log in, please contact us immediately at
<a href="mailto:info@goodwalk.co.nz">info@goodwalk.co.nz</a>.</p>
</div>""")
text_body = (
f"Hi {first_name},\n\n"
f"Your Goodwalk login code is: {code}\n\n"
"This code expires in 10 minutes.\n\n"
"If you didn't request this, please contact us immediately."
)
await send_email(to_address, subject, html_body, text_body)
async def send_onboarding_invite(to_address: str, first_name: str, magic_url: str) -> None:
subject = "You're invited to complete your Goodwalk onboarding"
html_body = _base_html(f"""
<div class="body">
<p>Hi {first_name}!</p>
<p>Thanks for getting in touch with Goodwalk. We've opened your onboarding invitation so you can complete your details and sign your service agreement.</p>
<p><a href="{magic_url}" style="display:inline-block;padding:12px 18px;border-radius:12px;background:#FFD100;color:#002842;text-decoration:none;font-weight:700;">Start onboarding &rarr;</a></p>
<p class="expiry">This link is valid for 7 days and can only be used once.</p>
<p>Once your onboarding is complete and your contract is signed, we'll activate your members account.</p>
<p>If you have any questions, reach us at <a href="mailto:info@goodwalk.co.nz">info@goodwalk.co.nz</a>.</p>
</div>""")
text_body = (
f"Hi {first_name},\n\n"
"We've opened your Goodwalk onboarding invitation.\n\n"
f"Click this link to get started (valid for 7 days):\n{magic_url}\n\n"
"Once your onboarding is complete and your contract is signed, we'll activate your members account.\n\n"
"Questions? Email info@goodwalk.co.nz"
)
await send_email(to_address, subject, html_body, text_body)