Coverage for services/email_service.py: 46.67%

13 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-04-13 14:11 +0000

1""" 

2Email service using Resend for transactional emails. 

3""" 

4 

5from __future__ import annotations 

6 

7import logging 

8import os 

9 

10import resend 

11 

12logger = logging.getLogger(__name__) 

13 

14 

15class EmailService: 

16 """Thin wrapper around the Resend SDK for sending transactional emails.""" 

17 

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

25 

26 def send_password_reset(self, to_email: str, reset_token: str, username: str) -> bool: 

27 """ 

28 Send a password reset email via Resend. 

29 

30 Args: 

31 to_email: Recipient email address 

32 reset_token: Signed JWT reset token 

33 username: User's username (for personalisation) 

34 

35 Returns: 

36 True on success, False on failure 

37 """ 

38 reset_url = f"{self.app_base_url}/?reset_token={reset_token}" 

39 

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

68 

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 ) 

76 

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