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
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""
2Email Utilities Module
4This module provides email sending functionality for the application.
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.
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.
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
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)
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"""
48import logging
49import os
50import socket
51from typing import List, Optional
53from fastapi import HTTPException, status
54from fastapi_mail import ConnectionConfig, FastMail, MessageSchema
55from pydantic import EmailStr
56from server.utilities.error_detail import safe_detail
58# Configure logging
59logger = logging.getLogger(__name__)
62def is_mailhog_available() -> bool:
63 """
64 Check if MailHog is available for local development email testing.
66 MailHog is a local email testing tool that captures emails without sending them.
67 Useful for development environments to test email functionality.
69 Returns:
70 bool: True if MailHog is reachable on localhost:1025, False otherwise
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"))
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
90def get_mail_config() -> ConnectionConfig:
91 """
92 Get email configuration based on environment.
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
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.
104 Returns:
105 ConnectionConfig: FastMail connection configuration
107 Raises:
108 ValueError: If required Gmail credentials are missing in production mode
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")
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 )
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")
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 )
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
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.
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)
219 Raises:
220 HTTPException: If email sending fails or email system is not configured
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 )
237 try:
238 message = MessageSchema(
239 subject=subject,
240 recipients=[email],
241 body=body,
242 subtype="html" if is_html else "plain",
243 )
245 await fm.send_message(message)
246 logger.info(f"Custom email sent successfully to {email}")
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 )