""" 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"""

🐾 Goodwalk

Auckland's favourite dog walking service

{content}
""" 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"""

Hi {first_name}!

Welcome to the Goodwalk Members Area. Use the code below to claim your account. It expires in 15 minutes.

{code}

This code is valid for 15 minutes and can only be used once.

If you didn't request this, please contact us at info@goodwalk.co.nz.

""") 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"""

Hi {first_name}!

Here's your one-time login code for the Goodwalk Members Area. It expires in 10 minutes.

{code}

This code is valid for 10 minutes and can only be used once.

If you didn't try to log in, please contact us immediately at info@goodwalk.co.nz.

""") 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"""

Hi {first_name}!

Thanks for getting in touch with Goodwalk. We've opened your onboarding invitation so you can complete your details and sign your service agreement.

Start onboarding →

This link is valid for 7 days and can only be used once.

Once your onboarding is complete and your contract is signed, we'll activate your members account.

If you have any questions, reach us at info@goodwalk.co.nz.

""") 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)