Skip to content

Commit 041dd21

Browse files
Merge pull request #1018 from contributor (Email Service for Password Reset)
2 parents 12fe8c9 + 5972916 commit 041dd21

8 files changed

Lines changed: 392 additions & 23 deletions

File tree

‎backend/app.py‎

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,14 @@
8484
request_password_reset,
8585
reset_password_with_token,
8686
)
87+
from email_service import (
88+
build_password_reset_url,
89+
is_email_configured,
90+
send_password_reset_email,
91+
)
8792
from collections import defaultdict, deque
8893
from math import ceil
8994
from time import time
90-
from urllib.parse import quote
9195
from error_responses import (
9296
ErrorCodes, error_response, success_response,
9397
validation_error, missing_fields_error, invalid_json_error,
@@ -1975,20 +1979,36 @@ def forgot_password(validated_data):
19751979
logger.error("forgot-password database error: %s", e, exc_info=True)
19761980

19771981
response_data = {"message": FORGOT_PASSWORD_MESSAGE}
1978-
1979-
if plain_token and app_config.is_development():
1980-
frontend_base = os.getenv(
1981-
'FRONTEND_ORIGIN',
1982-
'http://127.0.0.1:5500',
1983-
).rstrip('/')
1984-
response_data["reset_url"] = (
1985-
f"{frontend_base}/pages/auth.html?token={quote(plain_token)}"
1986-
)
1987-
logger.info(
1988-
"Dev password reset link for %s: %s",
1989-
validated_data.email,
1990-
response_data["reset_url"],
1991-
)
1982+
frontend_base = os.getenv('FRONTEND_ORIGIN', 'http://127.0.0.1:5500').rstrip('/')
1983+
email_config = app_config.email
1984+
1985+
if plain_token:
1986+
reset_url = build_password_reset_url(plain_token, frontend_base)
1987+
if is_email_configured(email_config):
1988+
send_result = send_password_reset_email(
1989+
validated_data.email,
1990+
reset_url,
1991+
email_config,
1992+
)
1993+
if not send_result.ok:
1994+
logger.error(
1995+
"Password reset email not sent for %s: %s",
1996+
validated_data.email,
1997+
send_result.detail,
1998+
)
1999+
elif app_config.is_development():
2000+
response_data["reset_url"] = reset_url
2001+
logger.info(
2002+
"Dev password reset link for %s (email not configured): %s",
2003+
validated_data.email,
2004+
reset_url,
2005+
)
2006+
elif app_config.is_production():
2007+
logger.warning(
2008+
"Password reset token created but EMAIL_* is not configured; "
2009+
"user %s will not receive mail.",
2010+
validated_data.email,
2011+
)
19922012

19932013
return success_response(data=response_data)
19942014
except Exception as e:

‎backend/config.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,18 +170,28 @@ def from_env(cls) -> 'GoogleOAuthConfig':
170170

171171
@dataclass
172172
class EmailConfig:
173-
"""Email service configuration (e.g., SendGrid, Mailgun)."""
173+
"""Email service configuration (SendGrid API or SMTP)."""
174174
api_key: Optional[str]
175175
from_email: Optional[str]
176176
service_provider: str = 'sendgrid'
177-
177+
smtp_host: Optional[str] = None
178+
smtp_port: int = 587
179+
smtp_username: Optional[str] = None
180+
smtp_password: Optional[str] = None
181+
smtp_use_tls: bool = True
182+
178183
@classmethod
179184
def from_env(cls) -> 'EmailConfig':
180185
"""Create email config from environment variables."""
181186
return cls(
182187
api_key=os.getenv('EMAIL_API_KEY'),
183188
from_email=os.getenv('EMAIL_FROM'),
184-
service_provider=os.getenv('EMAIL_SERVICE', 'sendgrid')
189+
service_provider=os.getenv('EMAIL_SERVICE', 'sendgrid'),
190+
smtp_host=os.getenv('EMAIL_SMTP_HOST'),
191+
smtp_port=int(os.getenv('EMAIL_SMTP_PORT', '587')),
192+
smtp_username=os.getenv('EMAIL_SMTP_USER'),
193+
smtp_password=os.getenv('EMAIL_SMTP_PASSWORD'),
194+
smtp_use_tls=os.getenv('EMAIL_SMTP_USE_TLS', 'true').lower() == 'true',
185195
)
186196

187197

‎backend/email_service.py‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Outbound email delivery for password reset and other transactional mail."""
2+
from __future__ import annotations
3+
4+
import logging
5+
import smtplib
6+
from dataclasses import dataclass
7+
from email.message import EmailMessage
8+
from typing import Optional
9+
from urllib.parse import quote
10+
11+
import requests
12+
13+
try:
14+
from .config import EmailConfig
15+
except ImportError:
16+
from config import EmailConfig
17+
18+
logger = logging.getLogger(__name__)
19+
20+
SENDGRID_API_URL = 'https://api.sendgrid.com/v3/mail/send'
21+
PASSWORD_RESET_SUBJECT = 'Reset your BiblioDrift password'
22+
23+
24+
@dataclass(frozen=True)
25+
class EmailSendResult:
26+
"""Result of an email send attempt."""
27+
28+
ok: bool
29+
detail: str = ''
30+
31+
32+
def is_email_configured(config: EmailConfig) -> bool:
33+
"""Return True when enough settings exist to send mail for the chosen provider."""
34+
if not config.from_email or not str(config.from_email).strip():
35+
return False
36+
37+
provider = (config.service_provider or 'sendgrid').strip().lower()
38+
if provider == 'sendgrid':
39+
return bool(config.api_key and str(config.api_key).strip())
40+
if provider == 'smtp':
41+
return bool(config.smtp_host and str(config.smtp_host).strip())
42+
logger.warning("Unknown EMAIL_SERVICE '%s'; email disabled.", provider)
43+
return False
44+
45+
46+
def build_password_reset_url(plain_token: str, frontend_origin: str) -> str:
47+
"""Build the frontend URL users open to set a new password."""
48+
base = (frontend_origin or 'http://127.0.0.1:5500').rstrip('/')
49+
return f"{base}/pages/auth.html?token={quote(plain_token, safe='')}"
50+
51+
52+
def _password_reset_bodies(reset_url: str) -> tuple[str, str]:
53+
text = (
54+
'You requested a password reset for your BiblioDrift account.\n\n'
55+
f'Open this link to choose a new password (valid for 1 hour):\n{reset_url}\n\n'
56+
'If you did not request this, you can ignore this email.'
57+
)
58+
html = (
59+
'<p>You requested a password reset for your <strong>BiblioDrift</strong> account.</p>'
60+
f'<p><a href="{reset_url}">Reset your password</a> (link expires in 1 hour).</p>'
61+
'<p>If you did not request this, you can ignore this email.</p>'
62+
)
63+
return text, html
64+
65+
66+
def send_password_reset_email(
67+
to_email: str,
68+
reset_url: str,
69+
config: EmailConfig,
70+
) -> EmailSendResult:
71+
"""Send the password reset message. Does not raise on provider errors."""
72+
if not is_email_configured(config):
73+
return EmailSendResult(ok=False, detail='email_not_configured')
74+
75+
text_body, html_body = _password_reset_bodies(reset_url)
76+
provider = (config.service_provider or 'sendgrid').strip().lower()
77+
78+
try:
79+
if provider == 'sendgrid':
80+
return _send_via_sendgrid(
81+
to_email=to_email,
82+
subject=PASSWORD_RESET_SUBJECT,
83+
text_body=text_body,
84+
html_body=html_body,
85+
config=config,
86+
)
87+
if provider == 'smtp':
88+
return _send_via_smtp(
89+
to_email=to_email,
90+
subject=PASSWORD_RESET_SUBJECT,
91+
text_body=text_body,
92+
html_body=html_body,
93+
config=config,
94+
)
95+
return EmailSendResult(ok=False, detail=f'unsupported_provider:{provider}')
96+
except Exception as exc:
97+
logger.error('Password reset email failed for %s: %s', to_email, exc, exc_info=True)
98+
return EmailSendResult(ok=False, detail=str(exc))
99+
100+
101+
def _send_via_sendgrid(
102+
*,
103+
to_email: str,
104+
subject: str,
105+
text_body: str,
106+
html_body: str,
107+
config: EmailConfig,
108+
) -> EmailSendResult:
109+
payload = {
110+
'personalizations': [{'to': [{'email': to_email}]}],
111+
'from': {'email': config.from_email},
112+
'subject': subject,
113+
'content': [
114+
{'type': 'text/plain', 'value': text_body},
115+
{'type': 'text/html', 'value': html_body},
116+
],
117+
}
118+
response = requests.post(
119+
SENDGRID_API_URL,
120+
headers={
121+
'Authorization': f'Bearer {config.api_key}',
122+
'Content-Type': 'application/json',
123+
},
124+
json=payload,
125+
timeout=15,
126+
)
127+
if response.status_code in (200, 202):
128+
return EmailSendResult(ok=True)
129+
logger.error(
130+
'SendGrid rejected mail to %s: HTTP %s %s',
131+
to_email,
132+
response.status_code,
133+
response.text[:500],
134+
)
135+
return EmailSendResult(ok=False, detail=f'sendgrid_http_{response.status_code}')
136+
137+
138+
def _send_via_smtp(
139+
*,
140+
to_email: str,
141+
subject: str,
142+
text_body: str,
143+
html_body: str,
144+
config: EmailConfig,
145+
) -> EmailSendResult:
146+
message = EmailMessage()
147+
message['Subject'] = subject
148+
message['From'] = config.from_email
149+
message['To'] = to_email
150+
message.set_content(text_body)
151+
message.add_alternative(html_body, subtype='html')
152+
153+
host = config.smtp_host
154+
port = config.smtp_port
155+
use_tls = config.smtp_use_tls
156+
157+
if use_tls:
158+
with smtplib.SMTP(host, port, timeout=15) as smtp:
159+
smtp.ehlo()
160+
smtp.starttls()
161+
smtp.ehlo()
162+
if config.smtp_username and config.smtp_password:
163+
smtp.login(config.smtp_username, config.smtp_password)
164+
smtp.send_message(message)
165+
else:
166+
with smtplib.SMTP(host, port, timeout=15) as smtp:
167+
if config.smtp_username and config.smtp_password:
168+
smtp.login(config.smtp_username, config.smtp_password)
169+
smtp.send_message(message)
170+
171+
return EmailSendResult(ok=True)

‎config/.env.example‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,19 @@ OPENAI_API_KEY=your-openai-api-key
4040
GROQ_API_KEY=your-groq-api-key
4141
GEMINI_API_KEY=your-gemini-api-key
4242

43-
# Email Service Configuration
43+
# Email Service Configuration (password reset and transactional mail)
44+
# EMAIL_SERVICE: sendgrid | smtp
4445
EMAIL_SERVICE=sendgrid
45-
EMAIL_API_KEY=your-email-api-key
46+
EMAIL_API_KEY=your-sendgrid-api-key
4647
EMAIL_FROM=noreply@bibliodrift.com
48+
# SMTP (when EMAIL_SERVICE=smtp)
49+
EMAIL_SMTP_HOST=smtp.example.com
50+
EMAIL_SMTP_PORT=587
51+
EMAIL_SMTP_USER=
52+
EMAIL_SMTP_PASSWORD=
53+
EMAIL_SMTP_USE_TLS=true
54+
# Base URL for links in emails (no trailing slash)
55+
FRONTEND_ORIGIN=http://127.0.0.1:5500
4756

4857
# External Storage Configuration
4958
STORAGE_ACCESS_KEY=your-storage-access-key

‎frontend/js/auth-password-reset.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,16 @@
7272
|| 'If an account exists for that email, password reset instructions have been sent.';
7373

7474
if (res.ok) {
75+
const isLocalDev = ['localhost', '127.0.0.1'].includes(window.location.hostname);
7576
if (data.reset_url) {
7677
showForgotResetLink(data.reset_url);
7778
console.info('[Dev] Password reset link:', data.reset_url);
7879
notify('Use the reset link shown below.', 'info');
79-
} else {
80+
} else if (isLocalDev) {
8081
showForgotResetLinkMissing();
8182
notify(message, 'success');
83+
} else {
84+
notify(message, 'success');
8285
}
8386
} else {
8487
notify(data.error || data.message || 'Unable to send reset link.', 'error');

‎tests/conftest.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@
3636
os.environ.setdefault("GEMINI_API_KEY", "fake-gemini-key")
3737
os.environ.setdefault("FLASK_ENV", "testing")
3838

39-
from backend.app import app as flask_app
40-
from backend.models import db as _db, User
39+
from backend.app import app as flask_app, db as _db
40+
from backend.models import User
4141

4242

4343
@pytest.fixture(scope="session")

0 commit comments

Comments
 (0)