Coverage for server / utilities / email_utils.py: 74%

65 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-10-04 09:33 +0000

1""" 

2Email Utilities Module 

3 

4This module provides email sending functionality for the application. 

5 

6Transport priority: **Amazon SES → Gmail → MailHog**. SES is checked first and 

7deliberately so: the Gmail branch fires whenever GMAIL_USER is merely present, so 

8a leftover GMAIL_USER in a deployed .env would otherwise keep the old transport 

9alive right through an SES cutover, silently. The startup log names the selected 

10transport for exactly this reason. 

11 

12NOTE (2026-08-25): this module currently has no production caller — nothing in 

13server/ imports it. It is kept as the service's email utility and wired for SES 

14so that whoever does use it gets the right transport by default rather than 

15inheriting the Gmail-wins trap. Treat it as prepared, not active. 

16 

17Features: 

18- Amazon SES SMTP (production; us-west-2, support@eruditionsys.com) 

19- Gmail SMTP integration with App Password support 

20- Environment-based configuration 

21- Comprehensive error handling and logging 

22- Support for MailHog in local development 

23 

24Environment Variables Required: 

25 SES_SMTP_USER: SES SMTP username (an IAM access key id, NOT an email address) 

26 SES_SMTP_PASS: SES SMTP password — derived from the IAM secret key and 

27 REGION-BOUND. The raw IAM secret will not authenticate. 

28 SES_SMTP_HOST: default email-smtp.us-west-2.amazonaws.com 

29 SES_SMTP_PORT: default 587 (STARTTLS) 

30 SES_CONFIGURATION_SET: attributes bounces/complaints to a named config set 

31 MAIL_FROM: sender address; under SES this MUST match the address pinned by 

32 the IAM policy's ses:FromAddress condition 

33 GMAIL_USER: Gmail account email address 

34 GMAIL_APP_PASSWORD: Gmail App Password (not regular password) 

35 GMAIL_HOST: SMTP server (default: smtp.gmail.com) 

36 GMAIL_PORT: SMTP port (default: 587) 

37 GMAIL_USE_TLS: Use TLS (default: True) 

38 FRONTEND_URL: Frontend application URL for links 

39 MAIL_FROM_NAME: Display name for sender (default: Erudition Services) 

40 

41Gmail App Password Setup: 

42 1. Go to Google Account settings 

43 2. Enable 2-factor authentication 

44 3. Generate App Password: Security > 2-Step Verification > App Passwords 

45 4. Use the generated 16-character password as GMAIL_APP_PASSWORD 

46""" 

47 

48import logging 

49import os 

50import socket 

51from typing import List, Optional 

52 

53from fastapi import HTTPException, status 

54from fastapi_mail import ConnectionConfig, FastMail, MessageSchema 

55from pydantic import EmailStr 

56from server.utilities.error_detail import safe_detail 

57 

58# Configure logging 

59logger = logging.getLogger(__name__) 

60 

61 

62def is_mailhog_available() -> bool: 

63 """ 

64 Check if MailHog is available for local development email testing. 

65 

66 MailHog is a local email testing tool that captures emails without sending them. 

67 Useful for development environments to test email functionality. 

68 

69 Returns: 

70 bool: True if MailHog is reachable on localhost:1025, False otherwise 

71 

72 Notes: 

73 - MailHog default SMTP port: 1025 

74 - MailHog default web UI: http://localhost:8025 

75 - Set SMTP_HOST environment variable to override localhost 

76 """ 

77 try: 

78 smtp_host = os.getenv("SMTP_HOST", "localhost") 

79 smtp_port = int(os.getenv("SMTP_PORT", "1025")) 

80 

81 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 

82 s.settimeout(1) # 1 second timeout 

83 result = s.connect_ex((smtp_host, smtp_port)) 

84 return result == 0 

85 except Exception as e: 

86 logger.debug(f"MailHog availability check failed: {str(e)}") 

87 return False 

88 

89 

90def get_mail_config() -> ConnectionConfig: 

91 """ 

92 Get email configuration based on environment. 

93 

94 Configuration Priority: 

95 1. Amazon SES - if SES_SMTP_USER is configured (production) 

96 2. Gmail - if GMAIL_USER is configured (legacy staging) 

97 3. MailHog - if available and neither configured (local development) 

98 4. Error - if none is configured 

99 

100 SES is checked FIRST on purpose. The Gmail branch triggers on the mere 

101 presence of GMAIL_USER, so checking it first would let a stale value in a 

102 deployed .env silently survive an SES cutover. 

103 

104 Returns: 

105 ConnectionConfig: FastMail connection configuration 

106 

107 Raises: 

108 ValueError: If required Gmail credentials are missing in production mode 

109 

110 Gmail Configuration: 

111 Requires App Password, not regular Gmail password 

112 See module docstring for setup instructions 

113 """ 

114 # --- 1. Amazon SES (production) ------------------------------------- 

115 ses_user = os.getenv("SES_SMTP_USER") 

116 ses_password = os.getenv("SES_SMTP_PASS") 

117 

118 if ses_user and ses_password: 

119 ses_port = int(os.getenv("SES_SMTP_PORT", "587")) 

120 mail_from = os.getenv("MAIL_FROM", "support@eruditionsys.com") 

121 logger.info( 

122 "✅ Using Amazon SES for email " 

123 f"(host={os.getenv('SES_SMTP_HOST', 'email-smtp.us-west-2.amazonaws.com')}, from={mail_from})" 

124 ) 

125 return ConnectionConfig( 

126 MAIL_USERNAME=ses_user, 

127 MAIL_PASSWORD=ses_password, 

128 MAIL_FROM=mail_from, 

129 MAIL_FROM_NAME=os.getenv("MAIL_FROM_NAME", "EruditionTX"), 

130 MAIL_PORT=ses_port, 

131 MAIL_SERVER=os.getenv("SES_SMTP_HOST", "email-smtp.us-west-2.amazonaws.com"), 

132 # Port 465 is implicit TLS; 587 negotiates STARTTLS. Exactly one of 

133 # these may be true, and TLS is never optional — these messages carry 

134 # student names alongside credential-setting links. 

135 MAIL_STARTTLS=ses_port != 465, 

136 MAIL_SSL_TLS=ses_port == 465, 

137 USE_CREDENTIALS=True, 

138 VALIDATE_CERTS=True, 

139 ) 

140 

141 # --- 2. Gmail (legacy staging) --------------------------------------- 

142 gmail_user = os.getenv("GMAIL_USER") 

143 gmail_password = os.getenv("GMAIL_APP_PASSWORD") or os.getenv("GMAIL_PASSWORD") 

144 

145 if gmail_user and gmail_password: 

146 # Gmail is configured - use it regardless of MailHog 

147 logger.info(f"✅ Using Gmail SMTP for email: {gmail_user}") 

148 return ConnectionConfig( 

149 MAIL_USERNAME=gmail_user, 

150 MAIL_PASSWORD=gmail_password, 

151 MAIL_FROM=gmail_user, 

152 MAIL_PORT=int(os.getenv("GMAIL_PORT", "587")), 

153 MAIL_SERVER=os.getenv("GMAIL_HOST", "smtp.gmail.com"), 

154 MAIL_STARTTLS=os.getenv("GMAIL_USE_TLS", "True").lower() == "true", 

155 MAIL_SSL_TLS=False, 

156 USE_CREDENTIALS=True, 

157 VALIDATE_CERTS=True, 

158 ) 

159 # --- 3. MailHog (local development) ---------------------------------- 

160 elif is_mailhog_available(): 

161 # Neither SES nor Gmail configured, but MailHog is available 

162 logger.info("⚠️ Using MailHog for email (local development mode)") 

163 logger.info(f" Emails will be captured at: http://{os.getenv('SMTP_HOST', 'localhost')}:8025") 

164 return ConnectionConfig( 

165 MAIL_USERNAME=os.getenv("SMTP_USER", ""), 

166 MAIL_PASSWORD=os.getenv("SMTP_PASSWORD", ""), 

167 MAIL_FROM=os.getenv("MAIL_FROM", "noreply@eruditionservices.com"), 

168 MAIL_PORT=int(os.getenv("SMTP_PORT", "1025")), 

169 MAIL_SERVER=os.getenv("SMTP_HOST", "localhost"), 

170 MAIL_STARTTLS=False, 

171 MAIL_SSL_TLS=False, 

172 USE_CREDENTIALS=False, 

173 VALIDATE_CERTS=False, 

174 ) 

175 else: 

176 # Neither Gmail nor MailHog is configured 

177 logger.error("❌ No email system configured!") 

178 logger.error(" Set SES_SMTP_USER and SES_SMTP_PASS for production email") 

179 logger.error(" (legacy) or GMAIL_USER and GMAIL_APP_PASSWORD") 

180 logger.error(" Or run MailHog for local development testing") 

181 raise ValueError( 

182 "Email system not configured. " 

183 "Set SES_SMTP_USER and SES_SMTP_PASS environment variables" 

184 ) 

185 

186 

187# Initialize FastMail with the appropriate configuration 

188# Skip initialization in test mode 

189if os.getenv("TESTING") == "true": 

190 logger.info("Email system disabled (TESTING mode)") 

191 mail_config = None 

192 fm = None 

193else: 

194 try: 

195 mail_config = get_mail_config() 

196 fm = FastMail(mail_config) 

197 logger.info("Email system initialized successfully") 

198 except ValueError as e: 

199 logger.warning(f"Email system not initialized: {str(e)}") 

200 mail_config = None 

201 fm = None 

202 

203 

204async def send_custom_email( 

205 email: EmailStr, 

206 subject: str, 

207 body: str, 

208 is_html: bool = False 

209) -> None: 

210 """ 

211 Send a custom email with plain text or HTML content. 

212 

213 Args: 

214 email: Recipient's email address 

215 subject: Email subject line 

216 body: Email body content 

217 is_html: Whether body contains HTML (default: False) 

218 

219 Raises: 

220 HTTPException: If email sending fails or email system is not configured 

221 

222 Example: 

223 await send_custom_email( 

224 email="user@example.com", 

225 subject="Welcome!", 

226 body="<h1>Welcome to our platform!</h1>", 

227 is_html=True 

228 ) 

229 """ 

230 if fm is None: 

231 logger.error("Cannot send email: Email system not configured") 

232 raise HTTPException( 

233 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

234 detail="Email service is not configured. Please contact support." 

235 ) 

236 

237 try: 

238 message = MessageSchema( 

239 subject=subject, 

240 recipients=[email], 

241 body=body, 

242 subtype="html" if is_html else "plain", 

243 ) 

244 

245 await fm.send_message(message) 

246 logger.info(f"Custom email sent successfully to {email}") 

247 

248 except Exception as e: 

249 logger.error(f"Failed to send custom email to {email}: {str(e)}", exc_info=True) 

250 raise HTTPException( 

251 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

252 detail=safe_detail(e, "Failed to send email") 

253 )