import hashlib import os import secrets import smtplib import sqlite3 from datetime import datetime, timedelta, timezone from email.message import EmailMessage from pathlib import Path from dotenv import load_dotenv load_dotenv() DB_NAME = Path(__file__).parent.parent / "database" / "calendar.db" def get_connection(): conn = sqlite3.connect(DB_NAME) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON;") return conn def hash_token(token: str) -> str: return hashlib.sha256( token.encode("utf-8") ).hexdigest() # ============================================================ # EMAIL # ============================================================ def send_email( email: str, subject: str, plain_text: str, html: str, ): smtp_host = os.getenv("SMTP_HOST") smtp_port = int(os.getenv("SMTP_PORT", "587")) smtp_username = os.getenv("SMTP_USERNAME") smtp_password = os.getenv("SMTP_PASSWORD") smtp_from = os.getenv("SMTP_FROM") if not all([ smtp_host, smtp_username, smtp_password, smtp_from, ]): raise RuntimeError("SMTP configuration is incomplete") message = EmailMessage() message["From"] = smtp_from message["To"] = email message["Subject"] = subject # Plain-text fallback message.set_content(plain_text) # HTML version message.add_alternative( html, subtype="html", ) print(f"Sending email to {email}") with smtplib.SMTP( smtp_host, smtp_port, ) as smtp: print("SMTP connected") smtp.starttls() print("TLS started") smtp.login( smtp_username, smtp_password, ) print("SMTP login successful") smtp.send_message(message) print("Email sent") def send_verification_email( email: str, token: str, ): verification_url = ( "https://ben.de-roo.org" f"/api/verify-email?token={token}" ) plain_text = f"""Hello, You requested to use this email address for your Calendar account. Verify your email address using this link: {verification_url} This link expires after 24 hours. If you did not request this, you can ignore this email. """ html = f"""\

Hello,

You requested to use this email address for your Calendar account.

Verify your email address

This link expires after 24 hours.

If you did not request this, you can ignore this email.

""" send_email( email=email, subject="Verify your Calendar email address", plain_text=plain_text, html=html, ) def send_password_reset_email( email: str, token: str, ): password_reset_url = ( "https://ben.de-roo.org" f"/api/update-password_page?token={token}" ) plain_text = f"""Hello, You requested to reset your Calendar password. Reset your password using this link: {password_reset_url} This link expires after 1 hour. If you did not request this, you can ignore this email. """ html = f"""\

Hello,

You requested to reset your Calendar password.

Reset your password

This link expires after 1 hour.

If you did not request this, you can ignore this email.

""" send_email( email=email, subject="Reset your password", plain_text=plain_text, html=html, ) # ============================================================ # EMAIL ADDRESS VERIFICATION # ============================================================ def update_email( user_id: int, new_email: str, ): new_email = new_email.strip().lower() conn = get_connection() cur = conn.cursor() try: cur.execute( """ SELECT id FROM users WHERE id = ? """, (user_id,), ) if cur.fetchone() is None: return { "status": "failed", "reason": "user_not_found", } cur.execute( """ SELECT id FROM users WHERE email = ? AND id != ? """, ( new_email, user_id, ), ) if cur.fetchone() is not None: return { "status": "failed", "reason": "email_already_in_use", } cur.execute( """ UPDATE users SET email = ?, email_verified = 0 WHERE id = ? """, ( new_email, user_id, ), ) cur.execute( """ DELETE FROM email_verifications WHERE user_id = ? """, (user_id,), ) conn.commit() return { "status": "updated", "email": new_email, "email_verified": False, } except sqlite3.IntegrityError: conn.rollback() return { "status": "failed", "reason": "email_already_in_use", } finally: conn.close() def create_verification( user_id: int, ): token = secrets.token_urlsafe(32) token_hash = hash_token(token) expires_at = ( datetime.now(timezone.utc) + timedelta(hours=24) ).isoformat() conn = get_connection() cur = conn.cursor() try: cur.execute( """ DELETE FROM email_verifications WHERE user_id = ? """, (user_id,), ) cur.execute( """ INSERT INTO email_verifications ( user_id, token_hash, expires_at ) VALUES (?, ?, ?) """, ( user_id, token_hash, expires_at, ), ) conn.commit() return token finally: conn.close() def verify_email( token: str, ): token_hash = hash_token(token) conn = get_connection() cur = conn.cursor() try: cur.execute( """ SELECT id, user_id, expires_at FROM email_verifications WHERE token_hash = ? """, (token_hash,), ) verification = cur.fetchone() if verification is None: return { "status": "failed", "reason": "invalid_token", } expires_at = datetime.fromisoformat( verification["expires_at"] ) if datetime.now(timezone.utc) >= expires_at: cur.execute( """ DELETE FROM email_verifications WHERE id = ? """, (verification["id"],), ) conn.commit() return { "status": "failed", "reason": "token_expired", } cur.execute( """ UPDATE users SET email_verified = 1 WHERE id = ? """, (verification["user_id"],), ) cur.execute( """ DELETE FROM email_verifications WHERE id = ? """, (verification["id"],), ) conn.commit() return { "status": "verified", } finally: conn.close() # ============================================================ # PASSWORD RESET # ============================================================ def create_password_reset( user_id: int, ): token = secrets.token_urlsafe(32) token_hash = hash_token(token) expires_at = ( datetime.now(timezone.utc) + timedelta(hours=1) ).isoformat() conn = get_connection() cur = conn.cursor() try: cur.execute( """ DELETE FROM password_reset_tokens WHERE user_id = ? """, (user_id,), ) cur.execute( """ INSERT INTO password_reset_tokens ( user_id, token_hash, expires_at ) VALUES (?, ?, ?) """, ( user_id, token_hash, expires_at, ), ) conn.commit() return token finally: conn.close()