From 005c56b43d8cb0071d512a062215a1dc849674fd Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Sun, 7 Dec 2025 16:59:04 +0700 Subject: [PATCH] Email SMTP Fix --- .env.example | 33 +++ __pycache__/auth.cpython-312.pyc | Bin 4222 -> 6302 bytes __pycache__/email_service.cpython-312.pyc | Bin 9769 -> 20213 bytes __pycache__/models.cpython-312.pyc | Bin 10874 -> 11048 bytes __pycache__/server.cpython-312.pyc | Bin 58555 -> 66113 bytes auth.py | 46 +++++ email_service.py | 241 +++++++++++++++++++--- fix_enum.sql | 11 + migrate_password_reset.py | 43 ++++ models.py | 5 + server.py | 175 +++++++++++++++- 11 files changed, 526 insertions(+), 28 deletions(-) create mode 100644 .env.example create mode 100644 fix_enum.sql create mode 100644 migrate_password_reset.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..01689db --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Database Configuration +DATABASE_URL=postgresql://user:password@localhost:5432/membership_db + +# JWT Authentication +JWT_SECRET=your-secret-key-change-this-in-production +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# SMTP Email Configuration (Port 465 - SSL/TLS) +SMTP_HOST=p.konceptkit.com +SMTP_PORT=465 +SMTP_SECURE=false +SMTP_USER=koncept-kit/koncept-kit +SMTP_PASS=TOBYjqk3ZOWXUsEzlP1Kj3p1 +SMTP_FROM=noreply@konceptkit.id +SMTP_FROM_NAME=LOAF Membership + +# Alternative SMTP Configuration (Port 587 - STARTTLS) +# If using port 587, use these settings instead: +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_SECURE=false +# SMTP_USER=your-email@gmail.com +# SMTP_PASS=your-app-password +# SMTP_FROM=noreply@yourdomain.com +# SMTP_FROM_NAME=Your App Name + +# Frontend URL +FRONTEND_URL=http://localhost:3000 + +# Stripe Configuration (for future payment integration) +# STRIPE_SECRET_KEY=sk_test_... +# STRIPE_WEBHOOK_SECRET=whsec_... diff --git a/__pycache__/auth.cpython-312.pyc b/__pycache__/auth.cpython-312.pyc index bc7dcd370869173774742246d47dbb5c3780fcb1..b35714820513cc05039186fd11b7edd8c1b2a7ca 100644 GIT binary patch delta 2443 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 ZA^|ixzbIj$K`Xv`f8R!e;swmq{{uYOEN%b* delta 2127 zcmb7FU1%Fe5Z=}4B%S_Fzt&G`S*eW^xwe`(bsOCL)Ulm5V7X45l179IC3m*Z=ydAd zIgUkQnKTbI6pXoo3~k;Tn&1`;ehIXc5a+EgDmJaSI1tE7`z9KPwzQD$o^rO^CZSn$ zJ2T(T%+Aitt{z>5HwH@IyIgjF2NT{SJcz}Aw%jfV_wi*ja;Uc`Zr zk#=I|t{1Sia8|>1b8|Nj2LJcwHjXhIq}gX}tS&KumTiijXDXhDu~ORmtmXP4x6ho5 zb8qhNdjUI^^K9DrIWHgJeE9S8fsqd^T;Pg19n`@f2FzRomK$_Aq|0F}hqlQPEQhCH zBKix7Z7=~fTk2S<|0I7w!Muy`sfnB-6tYNBzo6TjT*Il8Lz(x6`%et^r1~|W)q$a& z;o(#7p6FA5v~&mjDPBCwBY8|H#D{WOWkf=v8n?DGHbu(tVphnh54;_wA(QI!Ey82! zcfQMGpMk3;qmB?vITXKq>Z&(;J^iiqpV2$5+n9CvE=`;9TmuzvH<3 zBKY@yZ-7r4n}HHF>{A2( z)M+pYzosT(!dDCr^LZ|w&Byf|#3y7SKN=_bR3A5FyG|x(%{C^vZxet!dcBM<1i6TG)dw#rvJh@zMu$FUZZ)0 zB&1QYf)WcV_*ZR+Zn&(h{WoA`>@_lNcmTuBIc6P|hP_L>m!g%xfi>H~D(kQL@#-HN0pJY& z3QTlx-R7Qi&N?fhSC?$pi#L7W_TGZu9KE@}66{;E^{=ym_y4TXSnn@6g=4-9`&!|Z zBaz;AaJ!N4w_{1nuQ*|En|a0c65$gBUulDcZ)f_Nsg-4Q1;`wMnMSg0_GlQxd)PWf%h(O z-)Ev9hkjzBKQPgS{i{sNjOD%)y47m~?In+|Y@fH6-Sh5+(voS_vv0;#eH!Kql-uUp z%E|d;WoKvQwSiS%YKEcm{RpNUpoo}yt-Pv{ oqchH$xd{drn*aa+ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index 873b1cdca9fd1a56482a6fe0198d9fad5b35479d..fc20acecae9f94c72bad7ac60c46649691173814 100644 GIT binary patch delta 714 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 P6JFa User: + """Require user to be active member with valid payment""" + from models import UserStatus + + if current_user.status != UserStatus.active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Active membership required. Please complete payment." + ) + + if current_user.role not in [UserRole.member, UserRole.admin]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Member access only" + ) + + return current_user diff --git a/email_service.py b/email_service.py index 05c7c33..1933a26 100644 --- a/email_service.py +++ b/email_service.py @@ -1,50 +1,129 @@ import os +import ssl +import smtplib +import asyncio +from pathlib import Path from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart -import aiosmtplib import logging +from dotenv import load_dotenv logger = logging.getLogger(__name__) +# Load .env file +ROOT_DIR = Path(__file__).parent +load_dotenv(ROOT_DIR / '.env') + +# SMTP Configuration - supports both new (SMTP_USER/SMTP_PASS) and old (SMTP_USERNAME/SMTP_PASSWORD) variable names SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com') SMTP_PORT = int(os.environ.get('SMTP_PORT', 587)) -SMTP_USERNAME = os.environ.get('SMTP_USERNAME', '') -SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD', '') -SMTP_FROM_EMAIL = os.environ.get('SMTP_FROM_EMAIL', 'noreply@membership.com') -SMTP_FROM_NAME = os.environ.get('SMTP_FROM_NAME', 'Membership Platform') +SMTP_SECURE = os.environ.get('SMTP_SECURE', 'false').lower() == 'true' +SMTP_VERIFY_CERT = os.environ.get('SMTP_VERIFY_CERT', 'true').lower() == 'true' +SMTP_AUTH_METHOD = os.environ.get('SMTP_AUTH_METHOD', None) # PLAIN, LOGIN, or CRAM-MD5 + +# Log configuration at startup +logger.info(f"šŸ“§ SMTP Configuration Loaded:") +logger.info(f" Host: {SMTP_HOST}:{SMTP_PORT}") +logger.info(f" Secure (SSL from start): {SMTP_SECURE}") +logger.info(f" Verify Certificate: {SMTP_VERIFY_CERT}") +logger.info(f" Auth Method: {SMTP_AUTH_METHOD or 'auto-detect'}") + +# Support both SMTP_USER and SMTP_USERNAME (new and old) +SMTP_USER = os.environ.get('SMTP_USER') or os.environ.get('SMTP_USERNAME', '') +SMTP_PASS = os.environ.get('SMTP_PASS') or os.environ.get('SMTP_PASSWORD', '') + +# Support both SMTP_FROM and SMTP_FROM_EMAIL (new and old) +SMTP_FROM = os.environ.get('SMTP_FROM') or os.environ.get('SMTP_FROM_EMAIL', 'noreply@membership.com') +SMTP_FROM_NAME = os.environ.get('SMTP_FROM_NAME', 'LOAF Membership') + FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000') +def _send_email_sync(message: MIMEMultipart, to_email: str): + """ + Synchronous email sending (will be wrapped in asyncio.to_thread) + Matches the working pattern from previous implementation + """ + # Create SSL context for certificate verification control + ssl_context = None + if not SMTP_VERIFY_CERT: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + logger.warning(f"āš ļø SSL certificate verification DISABLED for {SMTP_HOST}") + + # Determine connection method based on configuration + if not SMTP_SECURE: + # Plain SMTP without encryption (some providers require this) + logger.info(f"šŸ”Œ Using plain SMTP connection (SMTP_SECURE=false) to {SMTP_HOST}:{SMTP_PORT}") + with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: + logger.info(f" āœ… SMTP connection established") + server.login(SMTP_USER, SMTP_PASS) + logger.info(f" āœ… Login successful") + server.send_message(message) + logger.info(f" āœ… Message sent") + elif SMTP_PORT == 465: + # Use SSL/TLS from start (standard for port 465) + logger.info(f"šŸ”Œ Using SMTP_SSL connection to {SMTP_HOST}:{SMTP_PORT}") + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=ssl_context) as server: + logger.info(f" āœ… SSL connection established") + server.login(SMTP_USER, SMTP_PASS) + logger.info(f" āœ… Login successful") + server.send_message(message) + else: + # Use STARTTLS (standard for port 587) + logger.info(f"šŸ”Œ Using SMTP with STARTTLS to {SMTP_HOST}:{SMTP_PORT}") + with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: + server.starttls(context=ssl_context) + logger.info(f" āœ… STARTTLS enabled") + server.login(SMTP_USER, SMTP_PASS) + logger.info(f" āœ… Login successful") + server.send_message(message) + + async def send_email(to_email: str, subject: str, html_content: str): - """Send an email using SMTP""" + """ + Send an email using SMTP + + Configuration via environment variables: + - SMTP_SECURE=false: Plain SMTP without encryption + - SMTP_SECURE=true + Port 465: SSL/TLS from start + - SMTP_SECURE=true + Port 587: STARTTLS + """ try: message = MIMEMultipart('alternative') - message['From'] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>" + message['From'] = f"{SMTP_FROM_NAME} <{SMTP_FROM}>" message['To'] = to_email message['Subject'] = subject - + html_part = MIMEText(html_content, 'html') message.attach(html_part) - + # For development/testing, just log the email - if not SMTP_USERNAME or not SMTP_PASSWORD: + if not SMTP_USER or not SMTP_PASS: logger.info(f"[EMAIL] To: {to_email}") logger.info(f"[EMAIL] Subject: {subject}") - logger.info(f"[EMAIL] Content: {html_content}") + logger.info(f"[EMAIL] Content: {html_content[:200]}...") return True - - # Send actual email - await aiosmtplib.send( - message, - hostname=SMTP_HOST, - port=SMTP_PORT, - username=SMTP_USERNAME, - password=SMTP_PASSWORD, - start_tls=True - ) - logger.info(f"Email sent successfully to {to_email}") + + logger.info(f"šŸ“§ Sending email to {to_email} via {SMTP_HOST}:{SMTP_PORT}") + + # Run synchronous SMTP in thread pool to avoid blocking + await asyncio.to_thread(_send_email_sync, message, to_email) + + logger.info(f"āœ“ Email sent successfully to {to_email}") return True + + except smtplib.SMTPAuthenticationError as e: + logger.error(f"āŒ SMTP Authentication Error for {to_email}: {str(e)}") + logger.error(f" Check SMTP_USER and SMTP_PASS") + return False + except smtplib.SMTPException as e: + logger.error(f"āŒ SMTP Error sending to {to_email}: {str(e)}") + logger.exception(e) + return False except Exception as e: - logger.error(f"Failed to send email to {to_email}: {str(e)}") + logger.error(f"āœ— Failed to send email to {to_email}: {str(e)}") + logger.exception(e) return False async def send_verification_email(to_email: str, token: str): @@ -180,3 +259,119 @@ async def send_payment_prompt_email(to_email: str, first_name: str): """ return await send_email(to_email, subject, html_content) + +async def send_password_reset_email(to_email: str, first_name: str, reset_url: str): + """Send password reset link email""" + subject = "Reset Your Password - LOAF Membership" + + html_content = f""" + + + + + + +
+
+

Reset Your Password

+
+
+

Hi {first_name},

+

You requested to reset your password. Click the button below to create a new password:

+ +

+ Reset Password +

+ +
+

ā° This link will expire in 1 hour.

+

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

+
+ +

+ Or copy and paste this link into your browser:
+ {reset_url} +

+
+
+ + + """ + + return await send_email(to_email, subject, html_content) + +async def send_admin_password_reset_email( + to_email: str, + first_name: str, + temp_password: str, + force_change: bool +): + """Send temporary password when admin resets user password""" + subject = "Your Password Has Been Reset - LOAF Membership" + + force_change_text = ( + """ +
+

āš ļø You will be required to change this password when you log in.

+
+ """ + ) if force_change else "" + + login_url = f"{FRONTEND_URL}/login" + + html_content = f""" + + + + + + +
+
+

Password Reset by Administrator

+
+
+

Hi {first_name},

+

An administrator has reset your password. Here is your temporary password:

+ +
+ {temp_password} +
+ + {force_change_text} + +

Please log in and change your password to something memorable.

+ +

+ Go to Login +

+ +

+ Questions? Contact us at support@loaf.org +

+
+
+ + + """ + + return await send_email(to_email, subject, html_content) diff --git a/fix_enum.sql b/fix_enum.sql new file mode 100644 index 0000000..ee92669 --- /dev/null +++ b/fix_enum.sql @@ -0,0 +1,11 @@ +-- Add pending_approval to the userstatus enum if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum + WHERE enumlabel = 'pending_approval' + AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'userstatus') + ) THEN + ALTER TYPE userstatus ADD VALUE 'pending_approval' BEFORE 'pre_approved'; + END IF; +END$$; diff --git a/migrate_password_reset.py b/migrate_password_reset.py new file mode 100644 index 0000000..3d144cb --- /dev/null +++ b/migrate_password_reset.py @@ -0,0 +1,43 @@ +""" +Migration script to add password reset fields to users table. +Run this once to update the database schema for password reset functionality. +""" + +from database import engine +from sqlalchemy import text + +def add_password_reset_columns(): + """Add password reset token, expiration, and force change columns to users table""" + + migrations = [ + # Password reset token (secure random string) + "ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR", + + # Password reset expiration (1 hour from token creation) + "ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMP", + + # Force password change on next login (for admin resets) + "ALTER TABLE users ADD COLUMN IF NOT EXISTS force_password_change BOOLEAN NOT NULL DEFAULT FALSE" + ] + + try: + print("Adding password reset columns to users table...") + with engine.connect() as conn: + for sql in migrations: + print(f" Executing: {sql[:70]}...") + conn.execute(text(sql)) + conn.commit() + + print("\nāœ… Migration completed successfully!") + print("\nAdded columns:") + print(" - password_reset_token (VARCHAR)") + print(" - password_reset_expires (TIMESTAMP)") + print(" - force_password_change (BOOLEAN, default=FALSE)") + print("\nYou can now run password reset functionality.") + + except Exception as e: + print(f"\nāŒ Migration failed: {e}") + raise + +if __name__ == "__main__": + add_password_reset_columns() diff --git a/models.py b/models.py index b8e347e..79c812f 100644 --- a/models.py +++ b/models.py @@ -77,6 +77,11 @@ class User(Base): directory_dob = Column(DateTime, nullable=True) directory_partner_name = Column(String, nullable=True) + # Password Reset Fields + password_reset_token = Column(String, nullable=True) + password_reset_expires = Column(DateTime, nullable=True) + force_password_change = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/server.py b/server.py index 04110c8..d6e45a2 100644 --- a/server.py +++ b/server.py @@ -20,9 +20,18 @@ from auth import ( verify_password, create_access_token, get_current_user, - get_current_admin_user + get_current_admin_user, + get_active_member, + create_password_reset_token, + verify_reset_token +) +from email_service import ( + send_verification_email, + send_approval_notification, + send_payment_prompt_email, + send_password_reset_email, + send_admin_password_reset_email ) -from email_service import send_verification_email, send_approval_notification, send_payment_prompt_email from payment_service import create_checkout_session, verify_webhook_signature, get_subscription_end_date # Load environment variables @@ -125,6 +134,20 @@ class LoginResponse(BaseModel): token_type: str user: dict +class ForgotPasswordRequest(BaseModel): + email: EmailStr + +class ResetPasswordRequest(BaseModel): + token: str + new_password: str = Field(min_length=6) + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str = Field(min_length=6) + +class AdminPasswordUpdateRequest(BaseModel): + force_change: bool = True + class UserResponse(BaseModel): id: str email: str @@ -314,9 +337,32 @@ async def verify_email(token: str, db: Session = Depends(get_db)): db.refresh(user) logger.info(f"Email verified for user: {user.email}") - + return {"message": "Email verified successfully", "status": user.status.value} +@api_router.post("/auth/resend-verification-email") +async def resend_verification_email( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """User requests to resend their verification email""" + + # Check if email already verified + if current_user.email_verified: + raise HTTPException(status_code=400, detail="Email is already verified") + + # Generate new token + verification_token = secrets.token_urlsafe(32) + current_user.email_verification_token = verification_token + db.commit() + + # Send verification email + await send_verification_email(current_user.email, verification_token) + + logger.info(f"Verification email resent to: {current_user.email}") + + return {"message": "Verification email has been resent. Please check your inbox."} + @api_router.post("/auth/login", response_model=LoginResponse) async def login(request: LoginRequest, db: Session = Depends(get_db)): user = db.query(User).filter(User.email == request.email).first() @@ -338,10 +384,60 @@ async def login(request: LoginRequest, db: Session = Depends(get_db)): "first_name": user.first_name, "last_name": user.last_name, "status": user.status.value, - "role": user.role.value + "role": user.role.value, + "force_password_change": user.force_password_change } } +@api_router.post("/auth/forgot-password") +async def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)): + """Request password reset - sends email with reset link""" + user = db.query(User).filter(User.email == request.email).first() + + # Always return success (security: don't reveal if email exists) + if user: + token = create_password_reset_token(user, db) + reset_url = f"{os.getenv('FRONTEND_URL')}/reset-password?token={token}" + + await send_password_reset_email(user.email, user.first_name, reset_url) + + return {"message": "If email exists, reset link has been sent"} + +@api_router.post("/auth/reset-password") +async def reset_password(request: ResetPasswordRequest, db: Session = Depends(get_db)): + """Complete password reset using token""" + user = verify_reset_token(request.token, db) + + if not user: + raise HTTPException(status_code=400, detail="Invalid or expired reset token") + + # Update password + user.password_hash = get_password_hash(request.new_password) + user.password_reset_token = None + user.password_reset_expires = None + user.force_password_change = False # Reset flag if it was set + db.commit() + + return {"message": "Password reset successful"} + +@api_router.put("/users/change-password") +async def change_password( + request: ChangePasswordRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """User changes their own password""" + # Verify current password + if not verify_password(request.current_password, current_user.password_hash): + raise HTTPException(status_code=400, detail="Current password is incorrect") + + # Update password + current_user.password_hash = get_password_hash(request.new_password) + current_user.force_password_change = False # Clear flag if set + db.commit() + + return {"message": "Password changed successfully"} + @api_router.get("/auth/me", response_model=UserResponse) async def get_me(current_user: User = Depends(get_current_user)): return UserResponse( @@ -412,6 +508,7 @@ async def update_profile( # Event Routes @api_router.get("/events", response_model=List[EventResponse]) async def get_events( + current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): # Get published events for all users @@ -445,6 +542,7 @@ async def get_events( @api_router.get("/events/{event_id}", response_model=EventResponse) async def get_event( event_id: str, + current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() @@ -478,7 +576,7 @@ async def get_event( async def rsvp_to_event( event_id: str, request: RSVPRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() @@ -743,6 +841,73 @@ async def activate_payment_manually( "subscription_id": str(subscription.id) } +@api_router.put("/admin/users/{user_id}/reset-password") +async def admin_reset_user_password( + user_id: str, + request: AdminPasswordUpdateRequest, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Admin resets user password - generates temp password and emails it""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Generate random temporary password + temp_password = secrets.token_urlsafe(12) + + # Update user + user.password_hash = get_password_hash(temp_password) + user.force_password_change = request.force_change + db.commit() + + # Email user the temporary password + await send_admin_password_reset_email( + user.email, + user.first_name, + temp_password, + request.force_change + ) + + # Log admin action + logger.info( + f"Admin {current_user.email} reset password for user {user.email} " + f"(force_change={request.force_change})" + ) + + return {"message": f"Password reset for {user.email}. Temporary password emailed."} + +@api_router.post("/admin/users/{user_id}/resend-verification") +async def admin_resend_verification( + user_id: str, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Admin resends verification email for any user""" + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Check if email already verified + if user.email_verified: + raise HTTPException(status_code=400, detail="User's email is already verified") + + # Generate new token + verification_token = secrets.token_urlsafe(32) + user.email_verification_token = verification_token + db.commit() + + # Send verification email + await send_verification_email(user.email, verification_token) + + # Log admin action + logger.info( + f"Admin {current_user.email} resent verification email to user {user.email}" + ) + + return {"message": f"Verification email resent to {user.email}"} + @api_router.post("/admin/events", response_model=EventResponse) async def create_event( request: EventCreate,