167 lines
7.0 KiB
Python
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 — 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 →</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)
|