Coverage for services/email_service.py: 46.67%
13 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-13 00:07 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-13 00:07 +0000
1"""
2Email service using Resend for transactional emails.
3"""
5from __future__ import annotations
7import logging
8import os
10import resend
12logger = logging.getLogger(__name__)
15class EmailService:
16 """Thin wrapper around the Resend SDK for sending transactional emails."""
18 def __init__(self) -> None:
19 api_key = os.getenv("RESEND_API_KEY")
20 if not api_key:
21 raise ValueError("RESEND_API_KEY environment variable is required")
22 resend.api_key = api_key
23 self.from_address = os.getenv("RESEND_FROM_ADDRESS", "noreply@contact.missingtable.com")
24 self.app_base_url = os.getenv("APP_BASE_URL", "https://missingtable.com")
26 def send_password_reset(self, to_email: str, reset_token: str, username: str) -> bool:
27 """
28 Send a password reset email via Resend.
30 Args:
31 to_email: Recipient email address
32 reset_token: Signed JWT reset token
33 username: User's username (for personalisation)
35 Returns:
36 True on success, False on failure
37 """
38 reset_url = f"{self.app_base_url}/?reset_token={reset_token}"
40 html_body = f"""
41<!DOCTYPE html>
42<html>
43<head>
44 <meta charset="utf-8" />
45 <title>Reset your Missing Table password</title>
46</head>
47<body style="font-family: Arial, sans-serif; background: #f3f4f6; padding: 32px;">
48 <div style="max-width: 480px; margin: 0 auto; background: #fff; border-radius: 8px; padding: 32px;">
49 <h2 style="color: #2563eb;">Reset your password</h2>
50 <p>Hi <strong>{username}</strong>,</p>
51 <p>We received a request to reset the password for your Missing Table account.</p>
52 <p>Click the button below to choose a new password. This link expires in <strong>1 hour</strong>.</p>
53 <a href="{reset_url}"
54 style="display: inline-block; margin: 16px 0; padding: 12px 24px;
55 background: #2563eb; color: #fff; border-radius: 6px;
56 text-decoration: none; font-weight: bold;">
57 Reset Password
58 </a>
59 <p style="color: #6b7280; font-size: 13px;">
60 If you didn't request this, you can safely ignore this email — your password won't change.
61 </p>
62 <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;" />
63 <p style="color: #9ca3af; font-size: 12px;">Missing Table · missingtable.com</p>
64 </div>
65</body>
66</html>
67"""
69 text_body = (
70 f"Hi {username},\n\n"
71 "We received a request to reset the password for your Missing Table account.\n\n"
72 f"Reset your password here (link expires in 1 hour):\n{reset_url}\n\n"
73 "If you didn't request this, you can safely ignore this email.\n\n"
74 "— Missing Table"
75 )
77 try:
78 resend.Emails.send(
79 {
80 "from": self.from_address,
81 "to": [to_email],
82 "subject": "Reset your Missing Table password",
83 "html": html_body,
84 "text": text_body,
85 }
86 )
87 logger.info("password_reset_email_sent", extra={"recipient": to_email[:3] + "***"})
88 return True
89 except Exception as e:
90 logger.error(f"Failed to send password reset email: {e}")
91 return False