From 4a39f278156ea31a869d0d2f401a833200b70f38 Mon Sep 17 00:00:00 2001 From: Saurabh Puri Date: Tue, 16 Dec 2025 10:50:07 +0530 Subject: [PATCH 1/6] feat: update env.sample file --- env.example | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/env.example b/env.example index f5848fa..cd1c9f5 100644 --- a/env.example +++ b/env.example @@ -32,3 +32,10 @@ NEXT_PUBLIC_GOOGLE_CLIENT_SECRET=your-client-secret NEXT_PUBLIC_GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback NEXT_PUBLIC_GOOGLE_AUTH_URL=https://accounts.google.com/o/oauth2/v2/auth +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=your-email@gmail.com +SMTP_PASSWORD=your-app-password +SMTP_FROM_EMAIL=your-email@gmail.com +FRONTEND_URL=http://localhost:3000 + From 7bf2f3e550c22021367b8885f8c4111d2c9fd763 Mon Sep 17 00:00:00 2001 From: Saurabh Puri Date: Tue, 16 Dec 2025 10:50:46 +0530 Subject: [PATCH 2/6] feat: format code --- frontend/app/components/APIKeysTable.tsx | 60 +++++++++++++----------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/frontend/app/components/APIKeysTable.tsx b/frontend/app/components/APIKeysTable.tsx index fd0160b..07d0f40 100644 --- a/frontend/app/components/APIKeysTable.tsx +++ b/frontend/app/components/APIKeysTable.tsx @@ -1,17 +1,17 @@ -'use client'; +"use client"; -import { useState, useEffect } from 'react'; -import { fetchAPIKeys, deleteAPIKey, type APIKey } from '../lib/api-service'; -import { Trash2 } from 'lucide-react'; +import { useState, useEffect } from "react"; +import { fetchAPIKeys, deleteAPIKey, type APIKey } from "../lib/api-service"; +import { Trash2 } from "lucide-react"; // Re-export APIKey type for use in other components export type { APIKey }; -type SortOrder = 'asc' | 'desc'; +type SortOrder = "asc" | "desc"; export default function APIKeysTable() { const [apiKeys, setApiKeys] = useState([]); - const [sortOrder, setSortOrder] = useState('desc'); + const [sortOrder, setSortOrder] = useState("desc"); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [deleting, setDeleting] = useState(null); @@ -20,7 +20,7 @@ export default function APIKeysTable() { // Load API keys on mount useEffect(() => { loadAPIKeys(); - },[]); + }, []); const loadAPIKeys = async () => { try { @@ -29,7 +29,7 @@ export default function APIKeysTable() { const keys = await fetchAPIKeys(); setApiKeys(keys); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load API keys'); + setError(err instanceof Error ? err.message : "Failed to load API keys"); } finally { setLoading(false); } @@ -46,13 +46,14 @@ export default function APIKeysTable() { setDeleting(apiKeyId); setDeleteError(null); await deleteAPIKey(apiKeyId); - + // Refetch the API keys list after successful deletion await loadAPIKeys(); } catch (err) { - const errorMsg = err instanceof Error ? err.message : 'Failed to delete API key'; + const errorMsg = + err instanceof Error ? err.message : "Failed to delete API key"; setDeleteError(errorMsg); - console.error('Delete error:', err); + console.error("Delete error:", err); } finally { setDeleting(null); } @@ -62,20 +63,20 @@ export default function APIKeysTable() { const dateA = new Date(a.created_at).getTime(); const dateB = new Date(b.created_at).getTime(); const comparison = dateB - dateA; - return sortOrder === 'desc' ? comparison : -comparison; + return sortOrder === "desc" ? comparison : -comparison; }); const toggleSortOrder = () => { - setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')); + setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); }; const formatDate = (dateString: string): string => { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", }); }; @@ -118,7 +119,7 @@ function TableHeader({ sortOrder, onToggleSort }: TableHeaderProps) { onClick={onToggleSort} className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-gray-700" > - {sortOrder === 'desc' ? 'Newest First' : 'Oldest First'} + {sortOrder === "desc" ? "Newest First" : "Oldest First"} @@ -169,7 +170,12 @@ interface TableBodyProps { deleting: string | null; } -function TableBody({ apiKeys, formatDate, onDelete, deleting }: TableBodyProps) { +function TableBody({ + apiKeys, + formatDate, + onDelete, + deleting, +}: TableBodyProps) { return (
@@ -225,18 +231,18 @@ function TableRow({ apiKey, formatDate, onDelete, isDeleting }: TableRowProps) { - {apiKey.is_active ? 'Active' : 'Inactive'} + {apiKey.is_active ? "Active" : "Inactive"} ); -} \ No newline at end of file +} From 13169f9e214eda4d9ec5c5048b45816053547e7a Mon Sep 17 00:00:00 2001 From: Saurabh Puri Date: Tue, 16 Dec 2025 10:51:34 +0530 Subject: [PATCH 3/6] feat : implement the forgot password and reset password functionality frontend --- frontend/app/forgot-password/page.tsx | 121 ++++++-- frontend/app/lib/api-service.ts | 143 ++++++---- frontend/app/reset-password/page.tsx | 393 ++++++++++++++++++++++++++ 3 files changed, 578 insertions(+), 79 deletions(-) create mode 100644 frontend/app/reset-password/page.tsx diff --git a/frontend/app/forgot-password/page.tsx b/frontend/app/forgot-password/page.tsx index e25da8b..b967280 100644 --- a/frontend/app/forgot-password/page.tsx +++ b/frontend/app/forgot-password/page.tsx @@ -1,18 +1,52 @@ -'use client'; +"use client"; -import { motion } from 'framer-motion'; -import Image from 'next/image'; -import Link from 'next/link'; -import { useState } from 'react'; +import { motion } from "framer-motion"; +import Image from "next/image"; +import Link from "next/link"; +import { useState } from "react"; +import { config } from "../lib/config"; export default function ForgotPasswordPage() { - const [email, setEmail] = useState(''); + const [email, setEmail] = useState(""); const [isSubmitted, setIsSubmitted] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - // TODO: Implement password reset email logic - setIsSubmitted(true); + setError(null); + setIsLoading(true); + + try { + const res = await fetch( + `${config.api.baseUrl}/api/v1/auth/forgot-password`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email }), + } + ); + + if (!res.ok) { + const data = await res.json(); + throw new Error( + data.detail || "Failed to send reset email. Please try again." + ); + } + + setIsSubmitted(true); + } catch (error) { + console.error("Error requesting password reset:", error); + setError( + error instanceof Error + ? error.message + : "Failed to send reset email. Please try again." + ); + } finally { + setIsLoading(false); + } }; return ( @@ -43,8 +77,12 @@ export default function ForgotPasswordPage() { <> {/* Header */}
-

Forgot Password?

-

No worries, we'll send you reset instructions

+

+ Forgot Password? +

+

+ No worries, we'll send you reset instructions +

{/* Forgot Password Form */} @@ -56,7 +94,10 @@ export default function ForgotPasswordPage() { transition={{ delay: 0.4, duration: 0.6 }} >
-
+ {error && ( + + {error} + + )} + - Reset Password + {isLoading ? "Sending..." : "Reset Password"} @@ -93,17 +145,30 @@ export default function ForgotPasswordPage() { >
- - + +
-

Check Your Email

+

+ Check Your Email +

- We've sent password reset instructions to {email} + We've sent password reset instructions to{" "} + {email}

- Didn't receive the email? Check your spam folder or{' '} + Didn't receive the email? Check your spam folder or{" "} + +

+ Must be at least 8 characters +

+ + +
+ +
+ setConfirmPassword(e.target.value)} + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all" + placeholder="Confirm new password" + required + minLength={8} + /> + +
+
+ + + {loading ? "Resetting Password..." : "Reset Password"} + + +
+ + Back to Login + +
+ + + + ); +} From 5c176d117de89c255ca4e5d95684cc35b7ec70d4 Mon Sep 17 00:00:00 2001 From: Saurabh Puri Date: Tue, 16 Dec 2025 10:52:20 +0530 Subject: [PATCH 4/6] feat : implement email service and reset password functionality from backend --- zaban_backend/alembic/env.py | 1 + ...1206_01_add_password_reset_tokens_table.py | 37 ++++++ zaban_backend/app/routes/auth.py | 80 ++++++++++++ zaban_backend/app/schemas/auth.py | 17 +++ zaban_backend/app/services/email_service.py | 122 ++++++++++++++++++ 5 files changed, 257 insertions(+) create mode 100644 zaban_backend/alembic/versions/20251206_01_add_password_reset_tokens_table.py create mode 100644 zaban_backend/app/services/email_service.py diff --git a/zaban_backend/alembic/env.py b/zaban_backend/alembic/env.py index 018d7c8..b56bcb3 100644 --- a/zaban_backend/alembic/env.py +++ b/zaban_backend/alembic/env.py @@ -6,6 +6,7 @@ from alembic import context from app.db.database import Base from app.models.user import User # ensure model is imported +from app.models.password_reset_token import PasswordResetToken # ensure model is imported # Load environment variables from .env file load_dotenv() diff --git a/zaban_backend/alembic/versions/20251206_01_add_password_reset_tokens_table.py b/zaban_backend/alembic/versions/20251206_01_add_password_reset_tokens_table.py new file mode 100644 index 0000000..2adc04c --- /dev/null +++ b/zaban_backend/alembic/versions/20251206_01_add_password_reset_tokens_table.py @@ -0,0 +1,37 @@ +"""add password_reset_tokens table + +Revision ID: 20251206_01 +Revises: 20251205_01 +Create Date: 2025-12-06 +""" + +from alembic import op +import sqlalchemy as sa +import sqlalchemy.dialects.postgresql as psql + + +# revision identifiers, used by Alembic. +revision = '20251206_01' +down_revision = '20251205_01' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'password_reset_tokens', + sa.Column('id', psql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column('user_id', psql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('token', sa.String(), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + ) + op.create_index('ix_password_reset_tokens_token', 'password_reset_tokens', ['token'], unique=True) + op.create_index('ix_password_reset_tokens_user_id', 'password_reset_tokens', ['user_id']) + + +def downgrade() -> None: + op.drop_index('ix_password_reset_tokens_user_id', table_name='password_reset_tokens') + op.drop_index('ix_password_reset_tokens_token', table_name='password_reset_tokens') + op.drop_table('password_reset_tokens') diff --git a/zaban_backend/app/routes/auth.py b/zaban_backend/app/routes/auth.py index cbd23f8..59d201f 100644 --- a/zaban_backend/app/routes/auth.py +++ b/zaban_backend/app/routes/auth.py @@ -1,4 +1,6 @@ import os +import secrets +from datetime import datetime, timezone, timedelta from fastapi import APIRouter, HTTPException, status, Header, Depends from ..schemas.auth import ( SSOLogin, @@ -7,14 +9,20 @@ SignupRequest, SignupResponse, SigninRequest, + ForgotPasswordRequest, + ForgotPasswordResponse, + ResetPasswordRequest, + ResetPasswordResponse, ) from passlib.context import CryptContext from ..services.google_oauth2 import google_oauth2_client +from ..services.email_service import get_email_service from ..core.security import create_access_token, verify_token, logout_token from ..core.api_key_auth import generate_api_key from sqlalchemy.orm import Session from sqlalchemy import select from ..models.user import User +from ..models.password_reset_token import PasswordResetToken from ..db.database import get_db @@ -143,3 +151,75 @@ def signin(payload: SigninRequest, db: Session = Depends(get_db)) -> TokenRespon return TokenResponse(access_token=token, token_type="bearer") +@router.post("/forgot-password", response_model=ForgotPasswordResponse) +def forgot_password(payload: ForgotPasswordRequest, db: Session = Depends(get_db)) -> ForgotPasswordResponse: + email = payload.email.lower() + + # Find user by email + user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() + + # Always return success to prevent email enumeration + # Only proceed if user exists and has a password (not SSO-only) + if user is not None and getattr(user, "hashed_password", None): + # Generate secure token (32 bytes = 64 hex characters) + reset_token = secrets.token_urlsafe(32) + + # Set expiration (1 hour from now) + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + + # Create password reset token record + reset_token_record = PasswordResetToken( + user_id=user.id, + token=reset_token, + expires_at=expires_at, + ) + db.add(reset_token_record) + db.commit() + + # Send email + email_service = get_email_service() + email_service.send_password_reset_email(email, reset_token) + + # Always return success message (security best practice) + return ForgotPasswordResponse( + message="If an account exists with this email, a password reset link has been sent." + ) + + +@router.post("/reset-password", response_model=ResetPasswordResponse) +def reset_password(payload: ResetPasswordRequest, db: Session = Depends(get_db)) -> ResetPasswordResponse: + # Find the reset token + token_record = db.execute( + select(PasswordResetToken) + .where(PasswordResetToken.token == payload.token) + .where(PasswordResetToken.used_at.is_(None)) + ).scalar_one_or_none() + + if token_record is None: + raise HTTPException(status_code=400, detail="Invalid or expired reset token") + + # Check if token has expired + if token_record.expires_at < datetime.now(timezone.utc): + raise HTTPException(status_code=400, detail="Reset token has expired") + + # Get the user + user = db.execute(select(User).where(User.id == token_record.user_id)).scalar_one_or_none() + + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + # Hash the new password + try: + hashed = pwd_context.hash(payload.new_password) + except Exception as e: + raise HTTPException(status_code=500, detail="Password hashing failed") from e + + # Update user's password + user.hashed_password = hashed + + # Mark token as used + token_record.used_at = datetime.now(timezone.utc) + + db.commit() + + return ResetPasswordResponse(message="Password has been reset successfully") diff --git a/zaban_backend/app/schemas/auth.py b/zaban_backend/app/schemas/auth.py index cd9ee4f..4ce1a7f 100644 --- a/zaban_backend/app/schemas/auth.py +++ b/zaban_backend/app/schemas/auth.py @@ -38,3 +38,20 @@ class SigninRequest(BaseModel): password: str +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ForgotPasswordResponse(BaseModel): + message: str = "If an account exists with this email, a password reset link has been sent." + + +class ResetPasswordRequest(BaseModel): + token: str + new_password: str + + +class ResetPasswordResponse(BaseModel): + message: str = "Password has been reset successfully" + + diff --git a/zaban_backend/app/services/email_service.py b/zaban_backend/app/services/email_service.py new file mode 100644 index 0000000..32a422c --- /dev/null +++ b/zaban_backend/app/services/email_service.py @@ -0,0 +1,122 @@ +import os +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Optional + + +class EmailService: + """Service for sending emails via SMTP""" + + def __init__(self): + self.frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + self.smtp_host = os.getenv("SMTP_HOST", "") + self.smtp_port = int(os.getenv("SMTP_PORT", "587")) + self.smtp_username = os.getenv("SMTP_USERNAME", "") + self.smtp_password = os.getenv("SMTP_PASSWORD", "") + self.smtp_from_email = os.getenv("SMTP_FROM_EMAIL", "") + + def _build_email_bodies(self, reset_link: str) -> tuple[str, str]: + """Return (text_body, html_body) for the reset email.""" + text_body = f""" + Password Reset Request + + Hello, + + We received a request to reset your password for your Zaban account. + + Click the link below to reset your password: + {reset_link} + + This link will expire in 1 hour. + + If you didn't request a password reset, please ignore this email. + + Best regards, + The Zaban Team + """ + + html_body = f""" + + + + + + + +
+

Password Reset Request

+

Hello,

+

We received a request to reset your password for your Zaban account.

+

Click the button below to reset your password:

+ Reset Password +

This link will expire in 1 hour.

+

If you didn't request a password reset, please ignore this email.

+ +
+ + + """ + return text_body, html_body + + def send_password_reset_email(self, to_email: str, reset_token: str) -> bool: + """ + Send password reset email to user via SMTP + + Args: + to_email: Recipient email address + reset_token: Password reset token + + Returns: + True if email sent successfully, False otherwise + """ + if not all([self.smtp_host, self.smtp_username, self.smtp_password, self.smtp_from_email]): + print("⚠️ SMTP is not configured. Email sending disabled.") + print(" Set SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, and SMTP_FROM_EMAIL to enable email.") + return False + + reset_link = f"{self.frontend_url}/reset-password?token={reset_token}" + text_body, html_body = self._build_email_bodies(reset_link) + + try: + msg = MIMEMultipart('alternative') + msg['Subject'] = "Reset Your Password - Zaban" + msg['From'] = self.smtp_from_email + msg['To'] = to_email + + part1 = MIMEText(text_body, 'plain') + part2 = MIMEText(html_body, 'html') + msg.attach(part1) + msg.attach(part2) + + with smtplib.SMTP(self.smtp_host, self.smtp_port) as server: + server.starttls() + server.login(self.smtp_username, self.smtp_password) + server.send_message(msg) + + print(f"✅ Password reset email sent to {to_email} via SMTP") + return True + + except Exception as e: + print(f"❌ Failed to send password reset email to {to_email}: {e}") + return False + + +# Singleton instance +_email_service: Optional[EmailService] = None + + +def get_email_service() -> EmailService: + """Get or create email service instance""" + global _email_service + if _email_service is None: + _email_service = EmailService() + return _email_service From ed11a5e344fe8c45ecc0b4f901f099c5fcba1311 Mon Sep 17 00:00:00 2001 From: Saurabh Puri Date: Tue, 16 Dec 2025 11:36:56 +0530 Subject: [PATCH 5/6] feat : add hide and show password functionality --- frontend/app/login/page.tsx | 38 ++++++++++++----- frontend/app/signup/page.tsx | 79 ++++++++++++++++++++++++++---------- 2 files changed, 84 insertions(+), 33 deletions(-) diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 750d62d..acec6e5 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -7,6 +7,7 @@ import { useState } from "react"; import { initiateGoogleLogin } from "../lib/auth"; import { config } from "../lib/config"; import { useRouter } from "next/navigation"; +import { Eye, EyeOff } from "lucide-react"; export default function LoginPage() { const router = useRouter(); @@ -15,6 +16,7 @@ export default function LoginPage() { password: "", }); const [error, setError] = useState(null); + const [showPassword, setShowPassword] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -118,17 +120,31 @@ export default function LoginPage() { > Password - +
+ + +
diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index 86c2c56..2b8226b 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -5,12 +5,15 @@ import Image from "next/image"; import Link from "next/link"; import { useState } from "react"; import { useRouter } from "next/navigation"; +import { Eye, EyeOff } from "lucide-react"; import { initiateGoogleLogin } from "../lib/auth"; import { config } from "../lib/config"; export default function SignupPage() { const router = useRouter(); const [error, setError] = useState(null); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [formData, setFormData] = useState({ firstName: "", lastName: "", @@ -184,17 +187,31 @@ export default function SignupPage() { > Password - +
+ + +
@@ -204,17 +221,35 @@ export default function SignupPage() { > Confirm Password - +
+ + +
From 96081bf2256871538c1dc6236d0176f95cca692e Mon Sep 17 00:00:00 2001 From: Saurabh Puri Date: Tue, 23 Dec 2025 16:35:51 +0530 Subject: [PATCH 6/6] feat: add forgot password functionality --- frontend/app/reset-password/page.tsx | 21 +++++++- zaban_backend/app/services/email_service.py | 55 ++++++++++----------- zaban_backend/pyproject.toml | 1 + 3 files changed, 47 insertions(+), 30 deletions(-) diff --git a/frontend/app/reset-password/page.tsx b/frontend/app/reset-password/page.tsx index f0c6192..5b64d1d 100644 --- a/frontend/app/reset-password/page.tsx +++ b/frontend/app/reset-password/page.tsx @@ -7,7 +7,9 @@ import { useState } from "react"; import { useSearchParams } from "next/navigation"; import { config } from "../lib/config"; -export default function ResetPasswordPage() { +import { Suspense } from "react"; + +function ResetPasswordContent() { const searchParams = useSearchParams(); const token = searchParams.get("token"); @@ -391,3 +393,20 @@ export default function ResetPasswordPage() {
); } + +export default function ResetPasswordPage() { + return ( + +
+
+
+
+ + } + > + +
+ ); +} diff --git a/zaban_backend/app/services/email_service.py b/zaban_backend/app/services/email_service.py index 32a422c..93e88d0 100644 --- a/zaban_backend/app/services/email_service.py +++ b/zaban_backend/app/services/email_service.py @@ -1,20 +1,16 @@ import os -import smtplib -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText from typing import Optional +from sendgrid import SendGridAPIClient +from sendgrid.helpers.mail import Mail class EmailService: - """Service for sending emails via SMTP""" + """Service for sending emails via SendGrid""" def __init__(self): self.frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") - self.smtp_host = os.getenv("SMTP_HOST", "") - self.smtp_port = int(os.getenv("SMTP_PORT", "587")) - self.smtp_username = os.getenv("SMTP_USERNAME", "") - self.smtp_password = os.getenv("SMTP_PASSWORD", "") - self.smtp_from_email = os.getenv("SMTP_FROM_EMAIL", "") + self.sendgrid_api_key = os.getenv("SENDGRID_API_KEY") + self.from_email = os.getenv("SENDGRID_FROM_EMAIL") def _build_email_bodies(self, reset_link: str) -> tuple[str, str]: """Return (text_body, html_body) for the reset email.""" @@ -69,7 +65,7 @@ def _build_email_bodies(self, reset_link: str) -> tuple[str, str]: def send_password_reset_email(self, to_email: str, reset_token: str) -> bool: """ - Send password reset email to user via SMTP + Send password reset email to user via SendGrid Args: to_email: Recipient email address @@ -78,32 +74,33 @@ def send_password_reset_email(self, to_email: str, reset_token: str) -> bool: Returns: True if email sent successfully, False otherwise """ - if not all([self.smtp_host, self.smtp_username, self.smtp_password, self.smtp_from_email]): - print("⚠️ SMTP is not configured. Email sending disabled.") - print(" Set SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, and SMTP_FROM_EMAIL to enable email.") + if not self.sendgrid_api_key or not self.from_email: + print("⚠️ SendGrid is not configured. Email sending disabled.") + print(" Set SENDGRID_API_KEY and SENDGRID_FROM_EMAIL to enable email.") return False reset_link = f"{self.frontend_url}/reset-password?token={reset_token}" text_body, html_body = self._build_email_bodies(reset_link) + message = Mail( + from_email=self.from_email, + to_emails=to_email, + subject='Reset Your Password - Zaban', + html_content=html_body, + plain_text_content=text_body + ) + try: - msg = MIMEMultipart('alternative') - msg['Subject'] = "Reset Your Password - Zaban" - msg['From'] = self.smtp_from_email - msg['To'] = to_email - - part1 = MIMEText(text_body, 'plain') - part2 = MIMEText(html_body, 'html') - msg.attach(part1) - msg.attach(part2) - - with smtplib.SMTP(self.smtp_host, self.smtp_port) as server: - server.starttls() - server.login(self.smtp_username, self.smtp_password) - server.send_message(msg) + sg = SendGridAPIClient(self.sendgrid_api_key) + response = sg.send(message) - print(f"✅ Password reset email sent to {to_email} via SMTP") - return True + if response.status_code in (200, 201, 202): + print(f"✅ Password reset email sent to {to_email} via SendGrid") + return True + else: + print(f"❌ Failed to send email. Status Code: {response.status_code}") + # print(response.body) + return False except Exception as e: print(f"❌ Failed to send password reset email to {to_email}: {e}") diff --git a/zaban_backend/pyproject.toml b/zaban_backend/pyproject.toml index ecbaf44..fbcae94 100644 --- a/zaban_backend/pyproject.toml +++ b/zaban_backend/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "ctranslate2>=4.0.0", "soundfile>=0.12.0", "parler-tts>=0.1.0", + "sendgrid>=6.11.0", ] [tool.uv]
{formatDate(apiKey.created_at)} - {apiKey.revoked_at ? formatDate(apiKey.revoked_at) : '—'} + {apiKey.revoked_at ? formatDate(apiKey.revoked_at) : "—"}