feat: enhance database reliability, add rate limiting, and improve email compatibility
This commit is contained in:
		
							parent
							
								
									2a2df9f6e5
								
							
						
					
					
						commit
						96f4243713
					
				
					 4 changed files with 441 additions and 348 deletions
				
			
		
							
								
								
									
										277
									
								
								database.py
									
										
									
									
									
								
							
							
						
						
									
										277
									
								
								database.py
									
										
									
									
									
								
							|  | @ -1,64 +1,213 @@ | ||||||
| import os | import os | ||||||
| import psycopg2 | import psycopg2 | ||||||
| from psycopg2 import pool, IntegrityError | from psycopg2 import pool, IntegrityError, OperationalError | ||||||
| from dotenv import load_dotenv | from dotenv import load_dotenv | ||||||
| import logging | import logging | ||||||
|  | import threading | ||||||
|  | import time | ||||||
|  | from contextlib import contextmanager | ||||||
| 
 | 
 | ||||||
| load_dotenv() | load_dotenv() | ||||||
| 
 | 
 | ||||||
| # Global connection pool | # Global connection pool | ||||||
| _connection_pool = None | _connection_pool = None | ||||||
|  | _pool_lock = threading.Lock() | ||||||
|  | _pool_stats = { | ||||||
|  |     'connections_created': 0, | ||||||
|  |     'connections_failed': 0, | ||||||
|  |     'pool_recreated': 0 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | logger = logging.getLogger(__name__) | ||||||
|  | 
 | ||||||
|  | class SimpleRobustPool: | ||||||
|  |     """Simplified robust connection pool""" | ||||||
|  |      | ||||||
|  |     def __init__(self, minconn=3, maxconn=15, **kwargs): | ||||||
|  |         self.minconn = minconn | ||||||
|  |         self.maxconn = maxconn | ||||||
|  |         self.kwargs = kwargs | ||||||
|  |         self.pool = None | ||||||
|  |         self._create_pool() | ||||||
|  |      | ||||||
|  |     def _create_pool(self): | ||||||
|  |         """Create or recreate the connection pool""" | ||||||
|  |         try: | ||||||
|  |             if self.pool: | ||||||
|  |                 try: | ||||||
|  |                     self.pool.closeall() | ||||||
|  |                 except: | ||||||
|  |                     pass | ||||||
|  |              | ||||||
|  |             self.pool = psycopg2.pool.ThreadedConnectionPool( | ||||||
|  |                 minconn=self.minconn, | ||||||
|  |                 maxconn=self.maxconn, | ||||||
|  |                 **self.kwargs | ||||||
|  |             ) | ||||||
|  |             _pool_stats['pool_recreated'] += 1 | ||||||
|  |             logger.info(f"Connection pool created: {self.minconn}-{self.maxconn} connections") | ||||||
|  |              | ||||||
|  |         except Exception as e: | ||||||
|  |             logger.error(f"Failed to create connection pool: {e}") | ||||||
|  |             raise | ||||||
|  |      | ||||||
|  |     def _test_connection(self, conn): | ||||||
|  |         """Simple connection test without transaction conflicts""" | ||||||
|  |         try: | ||||||
|  |             if conn.closed: | ||||||
|  |                 return False | ||||||
|  |              | ||||||
|  |             # Simple test that doesn't interfere with transactions | ||||||
|  |             conn.poll() | ||||||
|  |             return conn.status == psycopg2.extensions.STATUS_READY or conn.status == psycopg2.extensions.STATUS_BEGIN | ||||||
|  |              | ||||||
|  |         except Exception: | ||||||
|  |             return False | ||||||
|  |      | ||||||
|  |     def getconn(self, retry_count=2): | ||||||
|  |         """Get a connection with simplified retry logic""" | ||||||
|  |         for attempt in range(retry_count): | ||||||
|  |             try: | ||||||
|  |                 conn = self.pool.getconn() | ||||||
|  |                  | ||||||
|  |                 # Simple connection test | ||||||
|  |                 if not self._test_connection(conn): | ||||||
|  |                     logger.warning("Got bad connection, discarding and retrying") | ||||||
|  |                     try: | ||||||
|  |                         self.pool.putconn(conn, close=True) | ||||||
|  |                     except: | ||||||
|  |                         pass | ||||||
|  |                     continue | ||||||
|  |                  | ||||||
|  |                 _pool_stats['connections_created'] += 1 | ||||||
|  |                 return conn | ||||||
|  |                  | ||||||
|  |             except Exception as e: | ||||||
|  |                 logger.error(f"Error getting connection (attempt {attempt + 1}): {e}") | ||||||
|  |                 _pool_stats['connections_failed'] += 1 | ||||||
|  |                  | ||||||
|  |                 if attempt == retry_count - 1: | ||||||
|  |                     # Last attempt failed, try to recreate pool | ||||||
|  |                     logger.warning("Recreating connection pool due to failures") | ||||||
|  |                     try: | ||||||
|  |                         self._create_pool() | ||||||
|  |                         conn = self.pool.getconn() | ||||||
|  |                         if self._test_connection(conn): | ||||||
|  |                             return conn | ||||||
|  |                     except Exception as recreate_error: | ||||||
|  |                         logger.error(f"Failed to recreate pool: {recreate_error}") | ||||||
|  |                         raise | ||||||
|  |                  | ||||||
|  |                 # Wait before retry | ||||||
|  |                 time.sleep(0.5) | ||||||
|  |          | ||||||
|  |         raise Exception("Failed to get connection after retries") | ||||||
|  |      | ||||||
|  |     def putconn(self, conn, close=False): | ||||||
|  |         """Return connection to pool""" | ||||||
|  |         try: | ||||||
|  |             # Check if connection should be closed | ||||||
|  |             if conn.closed or close: | ||||||
|  |                 close = True | ||||||
|  |              | ||||||
|  |             self.pool.putconn(conn, close=close) | ||||||
|  |              | ||||||
|  |         except Exception as e: | ||||||
|  |             logger.error(f"Error returning connection to pool: {e}") | ||||||
|  |             try: | ||||||
|  |                 conn.close() | ||||||
|  |             except: | ||||||
|  |                 pass | ||||||
|  |      | ||||||
|  |     def closeall(self): | ||||||
|  |         """Close all connections""" | ||||||
|  |         if self.pool: | ||||||
|  |             self.pool.closeall() | ||||||
| 
 | 
 | ||||||
| def get_connection_pool(): | def get_connection_pool(): | ||||||
|     """Initialize and return the connection pool""" |     """Initialize and return the connection pool""" | ||||||
|     global _connection_pool |     global _connection_pool | ||||||
|  |     with _pool_lock: | ||||||
|         if _connection_pool is None: |         if _connection_pool is None: | ||||||
|             try: |             try: | ||||||
|             _connection_pool = psycopg2.pool.ThreadedConnectionPool( |                 _connection_pool = SimpleRobustPool( | ||||||
|                 minconn=2, |                     minconn=int(os.getenv('DB_POOL_MIN', 3)), | ||||||
|                 maxconn=20, |                     maxconn=int(os.getenv('DB_POOL_MAX', 15)), | ||||||
|                     host=os.getenv("PG_HOST"), |                     host=os.getenv("PG_HOST"), | ||||||
|                 port=os.getenv("PG_PORT", 5432), |                     port=int(os.getenv("PG_PORT", 5432)), | ||||||
|                 dbname=os.getenv("PG_DATABASE"), |                     database=os.getenv("PG_DATABASE"), | ||||||
|                     user=os.getenv("PG_USER"), |                     user=os.getenv("PG_USER"), | ||||||
|                     password=os.getenv("PG_PASSWORD"), |                     password=os.getenv("PG_PASSWORD"), | ||||||
|                 connect_timeout=5 |                     connect_timeout=int(os.getenv('DB_CONNECT_TIMEOUT', 10)), | ||||||
|  |                     application_name="rideaware_newsletter" | ||||||
|                 ) |                 ) | ||||||
|             logging.info("Database connection pool created successfully") |                 logger.info("Database connection pool initialized successfully") | ||||||
|  |                  | ||||||
|             except Exception as e: |             except Exception as e: | ||||||
|             logging.error(f"Error creating connection pool: {e}") |                 logger.error(f"Error creating connection pool: {e}") | ||||||
|                 raise |                 raise | ||||||
|  |          | ||||||
|         return _connection_pool |         return _connection_pool | ||||||
| 
 | 
 | ||||||
| def get_connection(): | @contextmanager | ||||||
|     """Get a connection from the pool""" | def get_db_connection(): | ||||||
|  |     """Context manager for database connections""" | ||||||
|  |     conn = None | ||||||
|     try: |     try: | ||||||
|         pool = get_connection_pool() |         pool = get_connection_pool() | ||||||
|         conn = pool.getconn() |         conn = pool.getconn() | ||||||
|         if conn.closed: |         yield conn | ||||||
|             # Connection is closed, remove it and get a new one |          | ||||||
|             pool.putconn(conn, close=True) |  | ||||||
|             conn = pool.getconn() |  | ||||||
|         return conn |  | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         logging.error(f"Error getting connection from pool: {e}") |         logger.error(f"Database connection error: {e}") | ||||||
|  |         if conn: | ||||||
|  |             try: | ||||||
|  |                 conn.rollback() | ||||||
|  |             except: | ||||||
|  |                 pass | ||||||
|         raise |         raise | ||||||
|          |          | ||||||
| def return_connection(conn): |     finally: | ||||||
|     """Return a connection to the pool""" |         if conn: | ||||||
|             try: |             try: | ||||||
|                 pool = get_connection_pool() |                 pool = get_connection_pool() | ||||||
|                 pool.putconn(conn) |                 pool.putconn(conn) | ||||||
|             except Exception as e: |             except Exception as e: | ||||||
|         logging.error(f"Error returning connection to pool: {e}") |                 logger.error(f"Error returning connection: {e}") | ||||||
|  | 
 | ||||||
|  | def get_connection(): | ||||||
|  |     """Get a connection from the pool (legacy interface)""" | ||||||
|  |     try: | ||||||
|  |         pool = get_connection_pool() | ||||||
|  |         return pool.getconn() | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Error getting connection from pool: {e}") | ||||||
|  |         raise | ||||||
|  | 
 | ||||||
|  | def return_connection(conn): | ||||||
|  |     """Return a connection to the pool (legacy interface)""" | ||||||
|  |     try: | ||||||
|  |         pool = get_connection_pool() | ||||||
|  |         pool.putconn(conn) | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Error returning connection to pool: {e}") | ||||||
| 
 | 
 | ||||||
| def close_all_connections(): | def close_all_connections(): | ||||||
|     """Close all connections in the pool""" |     """Close all connections in the pool""" | ||||||
|     global _connection_pool |     global _connection_pool | ||||||
|  |     with _pool_lock: | ||||||
|         if _connection_pool: |         if _connection_pool: | ||||||
|  |             try: | ||||||
|                 _connection_pool.closeall() |                 _connection_pool.closeall() | ||||||
|  |                 logger.info("All database connections closed") | ||||||
|  |             except Exception as e: | ||||||
|  |                 logger.error(f"Error closing connections: {e}") | ||||||
|  |             finally: | ||||||
|                 _connection_pool = None |                 _connection_pool = None | ||||||
|         logging.info("All database connections closed") | 
 | ||||||
|  | def get_pool_stats(): | ||||||
|  |     """Get connection pool statistics""" | ||||||
|  |     return _pool_stats.copy() | ||||||
| 
 | 
 | ||||||
| def column_exists(cursor, table_name, column_name): | def column_exists(cursor, table_name, column_name): | ||||||
|     """Check if a column exists in a table""" |     """Check if a column exists in a table""" | ||||||
|  | @ -84,11 +233,9 @@ def index_exists(cursor, index_name): | ||||||
| 
 | 
 | ||||||
| def init_db(): | def init_db(): | ||||||
|     """Initialize database tables and indexes""" |     """Initialize database tables and indexes""" | ||||||
|     conn = None |     with get_db_connection() as conn: | ||||||
|         try: |         try: | ||||||
|         conn = get_connection() |             with conn.cursor() as cursor: | ||||||
|         cursor = conn.cursor() |  | ||||||
|          |  | ||||||
|                 # Create subscribers table |                 # Create subscribers table | ||||||
|                 cursor.execute(""" |                 cursor.execute(""" | ||||||
|                     CREATE TABLE IF NOT EXISTS subscribers ( |                     CREATE TABLE IF NOT EXISTS subscribers ( | ||||||
|  | @ -103,7 +250,7 @@ def init_db(): | ||||||
|                         ALTER TABLE subscribers  |                         ALTER TABLE subscribers  | ||||||
|                         ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |                         ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||||||
|                     """) |                     """) | ||||||
|             logging.info("Added created_at column to subscribers table") |                     logger.info("Added created_at column to subscribers table") | ||||||
|                  |                  | ||||||
|                 # Create newsletters table |                 # Create newsletters table | ||||||
|                 cursor.execute(""" |                 cursor.execute(""" | ||||||
|  | @ -116,107 +263,79 @@ def init_db(): | ||||||
|                 """) |                 """) | ||||||
|                  |                  | ||||||
|                 # Create indexes only if they don't exist |                 # Create indexes only if they don't exist | ||||||
|         if not index_exists(cursor, 'idx_newsletters_sent_at'): |                 indexes = [ | ||||||
|             cursor.execute("CREATE INDEX idx_newsletters_sent_at ON newsletters(sent_at DESC)") |                     ("idx_newsletters_sent_at", "CREATE INDEX IF NOT EXISTS idx_newsletters_sent_at ON newsletters(sent_at DESC)"), | ||||||
|             logging.info("Created index idx_newsletters_sent_at") |                     ("idx_subscribers_email", "CREATE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email)"), | ||||||
|  |                     ("idx_subscribers_created_at", "CREATE INDEX IF NOT EXISTS idx_subscribers_created_at ON subscribers(created_at DESC)") | ||||||
|  |                 ] | ||||||
|                  |                  | ||||||
|         if not index_exists(cursor, 'idx_subscribers_email'): |                 for index_name, create_sql in indexes: | ||||||
|             cursor.execute("CREATE INDEX idx_subscribers_email ON subscribers(email)") |                     cursor.execute(create_sql) | ||||||
|             logging.info("Created index idx_subscribers_email") |                     logger.info(f"Ensured index {index_name} exists") | ||||||
|          |  | ||||||
|         if not index_exists(cursor, 'idx_subscribers_created_at'): |  | ||||||
|             cursor.execute("CREATE INDEX idx_subscribers_created_at ON subscribers(created_at DESC)") |  | ||||||
|             logging.info("Created index idx_subscribers_created_at") |  | ||||||
|                  |                  | ||||||
|                 conn.commit() |                 conn.commit() | ||||||
|         cursor.close() |                 logger.info("Database tables and indexes initialized successfully") | ||||||
|         logging.info("Database tables and indexes initialized successfully") |  | ||||||
|                  |                  | ||||||
|         except Exception as e: |         except Exception as e: | ||||||
|         logging.error(f"Error initializing database: {e}") |             logger.error(f"Error initializing database: {e}") | ||||||
|         if conn: |  | ||||||
|             conn.rollback() |             conn.rollback() | ||||||
|             raise |             raise | ||||||
|     finally: |  | ||||||
|         if conn: |  | ||||||
|             return_connection(conn) |  | ||||||
| 
 | 
 | ||||||
| def add_email(email): | def add_email(email): | ||||||
|     """Add email to subscribers with connection pooling""" |     """Add email to subscribers with robust connection handling""" | ||||||
|     conn = None |     with get_db_connection() as conn: | ||||||
|         try: |         try: | ||||||
|         conn = get_connection() |             with conn.cursor() as cursor: | ||||||
|         cursor = conn.cursor() |  | ||||||
|                 cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) |                 cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) | ||||||
|                 conn.commit() |                 conn.commit() | ||||||
|         cursor.close() |                 logger.info(f"Email added successfully: {email}") | ||||||
|         logging.info(f"Email added successfully: {email}") |  | ||||||
|                 return True |                 return True | ||||||
|                  |                  | ||||||
|         except IntegrityError: |         except IntegrityError: | ||||||
|             # Email already exists |             # Email already exists | ||||||
|         if conn: |  | ||||||
|             conn.rollback() |             conn.rollback() | ||||||
|         logging.info(f"Email already exists: {email}") |             logger.info(f"Email already exists: {email}") | ||||||
|             return False |             return False | ||||||
|              |              | ||||||
|         except Exception as e: |         except Exception as e: | ||||||
|         if conn: |  | ||||||
|             conn.rollback() |             conn.rollback() | ||||||
|         logging.error(f"Error adding email {email}: {e}") |             logger.error(f"Error adding email {email}: {e}") | ||||||
|             return False |             return False | ||||||
| 
 | 
 | ||||||
|     finally: |  | ||||||
|         if conn: |  | ||||||
|             return_connection(conn) |  | ||||||
| 
 |  | ||||||
| def remove_email(email): | def remove_email(email): | ||||||
|     """Remove email from subscribers with connection pooling""" |     """Remove email from subscribers with robust connection handling""" | ||||||
|     conn = None |     with get_db_connection() as conn: | ||||||
|         try: |         try: | ||||||
|         conn = get_connection() |             with conn.cursor() as cursor: | ||||||
|         cursor = conn.cursor() |  | ||||||
|                 cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) |                 cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) | ||||||
|                 conn.commit() |                 conn.commit() | ||||||
|                 rows_affected = cursor.rowcount |                 rows_affected = cursor.rowcount | ||||||
|         cursor.close() |  | ||||||
|                  |                  | ||||||
|                 if rows_affected > 0: |                 if rows_affected > 0: | ||||||
|             logging.info(f"Email removed successfully: {email}") |                     logger.info(f"Email removed successfully: {email}") | ||||||
|                     return True |                     return True | ||||||
|                 else: |                 else: | ||||||
|             logging.info(f"Email not found for removal: {email}") |                     logger.info(f"Email not found for removal: {email}") | ||||||
|                     return False |                     return False | ||||||
|                      |                      | ||||||
|         except Exception as e: |         except Exception as e: | ||||||
|         if conn: |  | ||||||
|             conn.rollback() |             conn.rollback() | ||||||
|         logging.error(f"Error removing email {email}: {e}") |             logger.error(f"Error removing email {email}: {e}") | ||||||
|             return False |             return False | ||||||
| 
 | 
 | ||||||
|     finally: |  | ||||||
|         if conn: |  | ||||||
|             return_connection(conn) |  | ||||||
| 
 |  | ||||||
| def get_subscriber_count(): | def get_subscriber_count(): | ||||||
|     """Get total number of subscribers""" |     """Get total number of subscribers""" | ||||||
|     conn = None |     with get_db_connection() as conn: | ||||||
|         try: |         try: | ||||||
|         conn = get_connection() |             with conn.cursor() as cursor: | ||||||
|         cursor = conn.cursor() |  | ||||||
|                 cursor.execute("SELECT COUNT(*) FROM subscribers") |                 cursor.execute("SELECT COUNT(*) FROM subscribers") | ||||||
|                 count = cursor.fetchone()[0] |                 count = cursor.fetchone()[0] | ||||||
|         cursor.close() |  | ||||||
|                 return count |                 return count | ||||||
|                  |                  | ||||||
|         except Exception as e: |         except Exception as e: | ||||||
|         logging.error(f"Error getting subscriber count: {e}") |             logger.error(f"Error getting subscriber count: {e}") | ||||||
|             return 0 |             return 0 | ||||||
| 
 | 
 | ||||||
|     finally: |  | ||||||
|         if conn: |  | ||||||
|             return_connection(conn) |  | ||||||
| 
 |  | ||||||
| # Cleanup function for graceful shutdown | # Cleanup function for graceful shutdown | ||||||
| import atexit | import atexit | ||||||
| atexit.register(close_all_connections) | atexit.register(close_all_connections) | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| gunicorn | gunicorn | ||||||
| flask |  | ||||||
| python-dotenv | python-dotenv | ||||||
| psycopg2-binary | psycopg2-binary | ||||||
|  | Flask | ||||||
|  | Flask-Limiter | ||||||
							
								
								
									
										49
									
								
								server.py
									
										
									
									
									
								
							
							
						
						
									
										49
									
								
								server.py
									
										
									
									
									
								
							|  | @ -5,6 +5,8 @@ from threading import Thread | ||||||
| import smtplib | import smtplib | ||||||
| from email.mime.text import MIMEText | from email.mime.text import MIMEText | ||||||
| from flask import Flask, render_template, request, jsonify, g | from flask import Flask, render_template, request, jsonify, g | ||||||
|  | from flask_limiter import Limiter | ||||||
|  | from flask_limiter.util import get_remote_address | ||||||
| from dotenv import load_dotenv | from dotenv import load_dotenv | ||||||
| from database import init_db, get_connection, return_connection, add_email, remove_email | from database import init_db, get_connection, return_connection, add_email, remove_email | ||||||
| 
 | 
 | ||||||
|  | @ -17,8 +19,19 @@ SMTP_PASSWORD = os.getenv('SMTP_PASSWORD') | ||||||
| 
 | 
 | ||||||
| app = Flask(__name__) | app = Flask(__name__) | ||||||
| 
 | 
 | ||||||
|  | # Rate limiting setup | ||||||
|  | limiter = Limiter( | ||||||
|  |     key_func=get_remote_address, | ||||||
|  |     app=app, | ||||||
|  |     default_limits=["1000 per hour", "100 per minute"], | ||||||
|  |     storage_uri="memory://" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
| # Configure logging | # Configure logging | ||||||
| logging.basicConfig(level=logging.INFO) | logging.basicConfig( | ||||||
|  |     level=logging.INFO, | ||||||
|  |     format='%(asctime)s %(levelname)s [%(name)s] %(message)s' | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| # Cache configuration | # Cache configuration | ||||||
| _newsletter_cache = {} | _newsletter_cache = {} | ||||||
|  | @ -132,24 +145,20 @@ def after_request(response): | ||||||
|      |      | ||||||
|     return response |     return response | ||||||
| 
 | 
 | ||||||
| def send_confirmation_email(to_address: str, unsubscribe_link: str): | def send_confirmation_email(to_address: str, html_body: str): | ||||||
|     """ |     """ | ||||||
|     Sends the HTML confirmation email to `to_address`. |     Sends the HTML confirmation email to `to_address`. | ||||||
|     This runs inside its own SMTP_SSL connection with reduced timeout. |     html_body is pre-rendered to avoid Flask context issues. | ||||||
|     """ |     """ | ||||||
|     try: |     try: | ||||||
|         subject = "Thanks for subscribing!" |         subject = "Thanks for subscribing!" | ||||||
|         html_body = render_template( |  | ||||||
|             "confirmation_email.html", |  | ||||||
|             unsubscribe_link=unsubscribe_link |  | ||||||
|         ) |  | ||||||
| 
 | 
 | ||||||
|         msg = MIMEText(html_body, "html", "utf-8") |         msg = MIMEText(html_body, "html", "utf-8") | ||||||
|         msg["Subject"] = subject |         msg["Subject"] = subject | ||||||
|         msg["From"] = SMTP_USER |         msg["From"] = SMTP_USER | ||||||
|         msg["To"] = to_address |         msg["To"] = to_address | ||||||
| 
 | 
 | ||||||
|         with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=5) as server: |         with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) as server: | ||||||
|             server.login(SMTP_USER, SMTP_PASSWORD) |             server.login(SMTP_USER, SMTP_PASSWORD) | ||||||
|             server.sendmail(SMTP_USER, [to_address], msg.as_string()) |             server.sendmail(SMTP_USER, [to_address], msg.as_string()) | ||||||
|              |              | ||||||
|  | @ -158,11 +167,11 @@ def send_confirmation_email(to_address: str, unsubscribe_link: str): | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         app.logger.error(f"Failed to send email to {to_address}: {e}") |         app.logger.error(f"Failed to send email to {to_address}: {e}") | ||||||
| 
 | 
 | ||||||
| def send_confirmation_async(email, unsubscribe_link): | def send_confirmation_async(email, html_body): | ||||||
|     """ |     """ | ||||||
|     Wrapper for threading.Thread target. |     Wrapper for threading.Thread target. | ||||||
|     """ |     """ | ||||||
|     send_confirmation_email(email, unsubscribe_link) |     send_confirmation_email(email, html_body) | ||||||
| 
 | 
 | ||||||
| @app.route("/", methods=["GET"]) | @app.route("/", methods=["GET"]) | ||||||
| def index(): | def index(): | ||||||
|  | @ -170,6 +179,7 @@ def index(): | ||||||
|     return render_template("index.html") |     return render_template("index.html") | ||||||
| 
 | 
 | ||||||
| @app.route("/subscribe", methods=["POST"]) | @app.route("/subscribe", methods=["POST"]) | ||||||
|  | @limiter.limit("5 per minute")  # Strict rate limit for subscriptions | ||||||
| def subscribe(): | def subscribe(): | ||||||
|     """Subscribe endpoint with optimized database handling""" |     """Subscribe endpoint with optimized database handling""" | ||||||
|     data = request.get_json() or {} |     data = request.get_json() or {} | ||||||
|  | @ -184,12 +194,17 @@ def subscribe(): | ||||||
|      |      | ||||||
|     try: |     try: | ||||||
|         if add_email(email): |         if add_email(email): | ||||||
|  |             # Render the template in the main thread (with Flask context) | ||||||
|             unsubscribe_link = f"{request.url_root}unsubscribe?email={email}" |             unsubscribe_link = f"{request.url_root}unsubscribe?email={email}" | ||||||
|  |             html_body = render_template( | ||||||
|  |                 "confirmation_email.html", | ||||||
|  |                 unsubscribe_link=unsubscribe_link | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|             # Start email sending in background thread |             # Start email sending in background thread with pre-rendered HTML | ||||||
|             Thread( |             Thread( | ||||||
|                 target=send_confirmation_async, |                 target=send_confirmation_async, | ||||||
|                 args=(email, unsubscribe_link), |                 args=(email, html_body), | ||||||
|                 daemon=True |                 daemon=True | ||||||
|             ).start() |             ).start() | ||||||
| 
 | 
 | ||||||
|  | @ -202,6 +217,7 @@ def subscribe(): | ||||||
|         return jsonify(error="Internal server error"), 500 |         return jsonify(error="Internal server error"), 500 | ||||||
| 
 | 
 | ||||||
| @app.route("/unsubscribe", methods=["GET"]) | @app.route("/unsubscribe", methods=["GET"]) | ||||||
|  | @limiter.limit("10 per minute") | ||||||
| def unsubscribe(): | def unsubscribe(): | ||||||
|     """Unsubscribe endpoint with optimized database handling""" |     """Unsubscribe endpoint with optimized database handling""" | ||||||
|     email = request.args.get("email") |     email = request.args.get("email") | ||||||
|  | @ -220,6 +236,7 @@ def unsubscribe(): | ||||||
|         return "Internal server error", 500 |         return "Internal server error", 500 | ||||||
| 
 | 
 | ||||||
| @app.route("/newsletters", methods=["GET"]) | @app.route("/newsletters", methods=["GET"]) | ||||||
|  | @limiter.limit("30 per minute") | ||||||
| def newsletters(): | def newsletters(): | ||||||
|     """ |     """ | ||||||
|     List all newsletters (newest first) with caching for better performance. |     List all newsletters (newest first) with caching for better performance. | ||||||
|  | @ -232,6 +249,7 @@ def newsletters(): | ||||||
|         return "Internal server error", 500 |         return "Internal server error", 500 | ||||||
| 
 | 
 | ||||||
| @app.route("/newsletter/<int:newsletter_id>", methods=["GET"]) | @app.route("/newsletter/<int:newsletter_id>", methods=["GET"]) | ||||||
|  | @limiter.limit("60 per minute") | ||||||
| def newsletter_detail(newsletter_id): | def newsletter_detail(newsletter_id): | ||||||
|     """ |     """ | ||||||
|     Show a single newsletter by its ID with caching. |     Show a single newsletter by its ID with caching. | ||||||
|  | @ -248,6 +266,7 @@ def newsletter_detail(newsletter_id): | ||||||
|         return "Internal server error", 500 |         return "Internal server error", 500 | ||||||
| 
 | 
 | ||||||
| @app.route("/admin/clear-cache", methods=["POST"]) | @app.route("/admin/clear-cache", methods=["POST"]) | ||||||
|  | @limiter.limit("5 per minute") | ||||||
| def clear_cache(): | def clear_cache(): | ||||||
|     """Admin endpoint to clear newsletter cache""" |     """Admin endpoint to clear newsletter cache""" | ||||||
|     try: |     try: | ||||||
|  | @ -258,10 +277,16 @@ def clear_cache(): | ||||||
|         return jsonify(error="Failed to clear cache"), 500 |         return jsonify(error="Failed to clear cache"), 500 | ||||||
| 
 | 
 | ||||||
| @app.route("/health", methods=["GET"]) | @app.route("/health", methods=["GET"]) | ||||||
|  | @limiter.limit("120 per minute") | ||||||
| def health_check(): | def health_check(): | ||||||
|     """Health check endpoint for monitoring""" |     """Health check endpoint for monitoring""" | ||||||
|     return jsonify(status="healthy", timestamp=time.time()), 200 |     return jsonify(status="healthy", timestamp=time.time()), 200 | ||||||
| 
 | 
 | ||||||
|  | # Rate limit error handler | ||||||
|  | @app.errorhandler(429) | ||||||
|  | def ratelimit_handler(e): | ||||||
|  |     return jsonify(error="Rate limit exceeded. Please try again later."), 429 | ||||||
|  | 
 | ||||||
| # Error handlers | # Error handlers | ||||||
| @app.errorhandler(404) | @app.errorhandler(404) | ||||||
| def not_found(error): | def not_found(error): | ||||||
|  |  | ||||||
|  | @ -13,6 +13,7 @@ | ||||||
|         </xml> |         </xml> | ||||||
|     </noscript> |     </noscript> | ||||||
|     <![endif]--> |     <![endif]--> | ||||||
|  |     <meta http-equiv="X-UA-Compatible" content="IE=edge"> | ||||||
|     <style> |     <style> | ||||||
|         /* Reset and base styles */ |         /* Reset and base styles */ | ||||||
|         * { |         * { | ||||||
|  | @ -22,12 +23,12 @@ | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         body { |         body { | ||||||
|  |             margin: 0; | ||||||
|  |             padding: 0; | ||||||
|             font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; |             font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | ||||||
|             line-height: 1.6; |             line-height: 1.6; | ||||||
|             color: #1a1a1a; |             color: #1a1a1a; | ||||||
|             background-color: #f8fafc; |             background-color: #f8fafc; | ||||||
|             margin: 0; |  | ||||||
|             padding: 0; |  | ||||||
|             -webkit-text-size-adjust: 100%; |             -webkit-text-size-adjust: 100%; | ||||||
|             -ms-text-size-adjust: 100%; |             -ms-text-size-adjust: 100%; | ||||||
|         } |         } | ||||||
|  | @ -40,71 +41,41 @@ | ||||||
| 
 | 
 | ||||||
|         img { |         img { | ||||||
|             border: 0; |             border: 0; | ||||||
|  |             max-width: 100%; | ||||||
|             height: auto; |             height: auto; | ||||||
|             line-height: 100%; |             line-height: 100%; | ||||||
|             outline: none; |             outline: none; | ||||||
|             text-decoration: none; |             text-decoration: none; | ||||||
|             -ms-interpolation-mode: bicubic; |  | ||||||
|             max-width: 100%; |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /* Container */ |         /* Email container */ | ||||||
|         .email-container { |         .email-wrapper { | ||||||
|             max-width: 600px; |             max-width: 600px; | ||||||
|             margin: 0 auto; |             margin: 0 auto; | ||||||
|             background-color: #ffffff; |             background-color: #ffffff; | ||||||
|             border-radius: 16px; |  | ||||||
|             box-shadow: 0 10px 30px rgba(30, 78, 156, 0.1); |  | ||||||
|             overflow: hidden; |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         .email-wrapper { |         /* Header styles */ | ||||||
|             background-color: #f8fafc; |  | ||||||
|             padding: 20px; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         /* Header */ |  | ||||||
|         .header { |         .header { | ||||||
|             background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 50%, #00d4ff 100%); |             background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); | ||||||
|  |             background-color: #1e4e9c; /* Fallback */ | ||||||
|             padding: 50px 30px; |             padding: 50px 30px; | ||||||
|             text-align: center; |             text-align: center; | ||||||
|             position: relative; |             color: white; | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .header::before { |  | ||||||
|             content: ''; |  | ||||||
|             position: absolute; |  | ||||||
|             top: 0; |  | ||||||
|             left: 0; |  | ||||||
|             right: 0; |  | ||||||
|             bottom: 0; |  | ||||||
|             background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="celebration" width="50" height="50" patternUnits="userSpaceOnUse"><circle cx="10" cy="10" r="2" fill="rgba(255,255,255,0.1)"/><circle cx="40" cy="25" r="1.5" fill="rgba(0,212,255,0.2)"/><circle cx="25" cy="40" r="1" fill="rgba(255,255,255,0.15)"/></pattern></defs><rect width="100%" height="100%" fill="url(%23celebration)"/></svg>'); |  | ||||||
|             opacity: 0.4; |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         .welcome-icon { |         .welcome-icon { | ||||||
|             width: 80px; |             font-size: 3rem; | ||||||
|             height: 80px; |             margin-bottom: 20px; | ||||||
|             background: rgba(255, 255, 255, 0.2); |             display: block; | ||||||
|             border-radius: 50%; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             justify-content: center; |  | ||||||
|             margin: 0 auto 20px; |  | ||||||
|             font-size: 2.5rem; |  | ||||||
|             position: relative; |  | ||||||
|             z-index: 1; |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         .logo { |         .logo { | ||||||
|             font-size: 24px; |             font-size: 24px; | ||||||
|             font-weight: 700; |             font-weight: 700; | ||||||
|             color: white; |             color: white; | ||||||
|             text-decoration: none; |  | ||||||
|             position: relative; |  | ||||||
|             z-index: 1; |  | ||||||
|             margin-bottom: 15px; |             margin-bottom: 15px; | ||||||
|             display: inline-block; |             display: block; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         .logo-accent { |         .logo-accent { | ||||||
|  | @ -116,19 +87,16 @@ | ||||||
|             font-size: 28px; |             font-size: 28px; | ||||||
|             font-weight: 800; |             font-weight: 800; | ||||||
|             margin: 0 0 10px; |             margin: 0 0 10px; | ||||||
|             position: relative; |  | ||||||
|             z-index: 1; |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         .header .subtitle { |         .subtitle { | ||||||
|             color: rgba(255, 255, 255, 0.9); |             color: rgba(255, 255, 255, 0.9); | ||||||
|             font-size: 16px; |             font-size: 16px; | ||||||
|             font-weight: 300; |             font-weight: 300; | ||||||
|             position: relative; |             margin: 0; | ||||||
|             z-index: 1; |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /* Content */ |         /* Content styles */ | ||||||
|         .content { |         .content { | ||||||
|             padding: 40px 30px; |             padding: 40px 30px; | ||||||
|             text-align: center; |             text-align: center; | ||||||
|  | @ -151,6 +119,7 @@ | ||||||
|         /* Feature highlights */ |         /* Feature highlights */ | ||||||
|         .features { |         .features { | ||||||
|             background: linear-gradient(135deg, rgba(30, 78, 156, 0.05) 0%, rgba(0, 212, 255, 0.05) 100%); |             background: linear-gradient(135deg, rgba(30, 78, 156, 0.05) 0%, rgba(0, 212, 255, 0.05) 100%); | ||||||
|  |             background-color: #f8fafc; /* Fallback */ | ||||||
|             border-radius: 12px; |             border-radius: 12px; | ||||||
|             padding: 30px 25px; |             padding: 30px 25px; | ||||||
|             margin: 30px 0; |             margin: 30px 0; | ||||||
|  | @ -165,10 +134,7 @@ | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         .feature-grid { |         .feature-grid { | ||||||
|             display: grid; |             width: 100%; | ||||||
|             grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); |  | ||||||
|             gap: 20px; |  | ||||||
|             margin-top: 20px; |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         .feature-item { |         .feature-item { | ||||||
|  | @ -176,6 +142,7 @@ | ||||||
|             padding: 15px; |             padding: 15px; | ||||||
|             background: white; |             background: white; | ||||||
|             border-radius: 10px; |             border-radius: 10px; | ||||||
|  |             margin-bottom: 15px; | ||||||
|             box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); |             box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | @ -198,7 +165,7 @@ | ||||||
|             line-height: 1.4; |             line-height: 1.4; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /* CTA Button */ |         /* CTA styles */ | ||||||
|         .cta-section { |         .cta-section { | ||||||
|             margin: 35px 0; |             margin: 35px 0; | ||||||
|             padding: 25px; |             padding: 25px; | ||||||
|  | @ -207,24 +174,22 @@ | ||||||
|         .cta-button { |         .cta-button { | ||||||
|             display: inline-block; |             display: inline-block; | ||||||
|             background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); |             background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); | ||||||
|             color: white; |             background-color: #1e4e9c; /* Fallback */ | ||||||
|  |             color: white !important; | ||||||
|             text-decoration: none; |             text-decoration: none; | ||||||
|             padding: 15px 35px; |             padding: 15px 35px; | ||||||
|             border-radius: 25px; |             border-radius: 25px; | ||||||
|             font-weight: 600; |             font-weight: 600; | ||||||
|             font-size: 16px; |             font-size: 16px; | ||||||
|             transition: all 0.3s ease; |  | ||||||
|             box-shadow: 0 5px 15px rgba(30, 78, 156, 0.3); |             box-shadow: 0 5px 15px rgba(30, 78, 156, 0.3); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         .cta-button:hover { |         .cta-button:hover { | ||||||
|             transform: translateY(-2px); |             background-color: #337cf2; | ||||||
|             box-shadow: 0 8px 25px rgba(30, 78, 156, 0.4); |  | ||||||
|             text-decoration: none; |             text-decoration: none; | ||||||
|             color: white; |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /* Social links */ |         /* Social section */ | ||||||
|         .social-section { |         .social-section { | ||||||
|             margin: 30px 0; |             margin: 30px 0; | ||||||
|             padding: 25px; |             padding: 25px; | ||||||
|  | @ -240,9 +205,7 @@ | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         .social-links { |         .social-links { | ||||||
|             display: flex; |             text-align: center; | ||||||
|             justify-content: center; |  | ||||||
|             gap: 15px; |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         .social-link { |         .social-link { | ||||||
|  | @ -250,22 +213,21 @@ | ||||||
|             width: 40px; |             width: 40px; | ||||||
|             height: 40px; |             height: 40px; | ||||||
|             background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); |             background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); | ||||||
|  |             background-color: #1e4e9c; /* Fallback */ | ||||||
|             color: white; |             color: white; | ||||||
|             text-decoration: none; |             text-decoration: none; | ||||||
|             border-radius: 50%; |             border-radius: 50%; | ||||||
|             display: flex; |             line-height: 40px; | ||||||
|             align-items: center; |  | ||||||
|             justify-content: center; |  | ||||||
|             font-size: 16px; |             font-size: 16px; | ||||||
|             transition: all 0.3s ease; |             margin: 0 7px; | ||||||
|  |             text-align: center; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         .social-link:hover { |         .social-link:hover { | ||||||
|             transform: translateY(-2px) scale(1.05); |             background-color: #337cf2; | ||||||
|             box-shadow: 0 5px 15px rgba(30, 78, 156, 0.3); |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /* Footer */ |         /* Footer styles */ | ||||||
|         .footer { |         .footer { | ||||||
|             background: #1a1a1a; |             background: #1a1a1a; | ||||||
|             color: white; |             color: white; | ||||||
|  | @ -277,9 +239,8 @@ | ||||||
|             margin-bottom: 20px; |             margin-bottom: 20px; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         .footer p { |         .footer-content p { | ||||||
|             margin: 5px 0; |             margin: 5px 0; | ||||||
|             opacity: 0.8; |  | ||||||
|             font-size: 14px; |             font-size: 14px; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | @ -300,58 +261,43 @@ | ||||||
|             text-decoration: underline; |             text-decoration: underline; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /* Links */ |  | ||||||
|         a { |  | ||||||
|             color: #337cf2; |  | ||||||
|             text-decoration: none; |  | ||||||
|             font-weight: 500; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         a:hover { |  | ||||||
|             color: #1e4e9c; |  | ||||||
|             text-decoration: underline; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         /* Mobile responsive */ |         /* Mobile responsive */ | ||||||
|         @media only screen and (max-width: 600px) { |         @media only screen and (max-width: 600px) { | ||||||
|             .email-wrapper { |             .email-wrapper { | ||||||
|                 padding: 10px; |                 width: 100% !important; | ||||||
|             } |             } | ||||||
|              |              | ||||||
|             .header { |             .header { | ||||||
|                 padding: 40px 20px; |                 padding: 40px 20px !important; | ||||||
|             } |             } | ||||||
|              |              | ||||||
|             .header h1 { |             .header h1 { | ||||||
|                 font-size: 24px; |                 font-size: 24px !important; | ||||||
|             } |             } | ||||||
|              |              | ||||||
|             .content { |             .content { | ||||||
|                 padding: 30px 20px; |                 padding: 30px 20px !important; | ||||||
|             } |             } | ||||||
|              |              | ||||||
|             .features { |             .features { | ||||||
|                 padding: 25px 20px; |                 padding: 25px 20px !important; | ||||||
|             } |             } | ||||||
|              |              | ||||||
|             .feature-grid { |             .feature-item { | ||||||
|                 grid-template-columns: 1fr; |                 margin-bottom: 10px !important; | ||||||
|                 gap: 15px; |  | ||||||
|             } |             } | ||||||
|              |              | ||||||
|             .social-links { |             .social-link { | ||||||
|                 gap: 10px; |                 margin: 0 5px !important; | ||||||
|             } |  | ||||||
|              |  | ||||||
|             .footer { |  | ||||||
|                 padding: 25px 20px; |  | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     </style> |     </style> | ||||||
| </head> | </head> | ||||||
| <body> | <body> | ||||||
|  |     <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f8fafc;"> | ||||||
|  |         <tr> | ||||||
|  |             <td align="center"> | ||||||
|                 <div class="email-wrapper"> |                 <div class="email-wrapper"> | ||||||
|         <div class="email-container"> |  | ||||||
|                     <!-- Header --> |                     <!-- Header --> | ||||||
|                     <div class="header"> |                     <div class="header"> | ||||||
|                         <div class="welcome-icon">🎉</div> |                         <div class="welcome-icon">🎉</div> | ||||||
|  | @ -368,7 +314,7 @@ | ||||||
|                             We're absolutely thrilled to have you join our community of passionate cyclists. Get ready for exclusive insights, training tips, feature updates, and much more delivered straight to your inbox. |                             We're absolutely thrilled to have you join our community of passionate cyclists. Get ready for exclusive insights, training tips, feature updates, and much more delivered straight to your inbox. | ||||||
|                         </p> |                         </p> | ||||||
| 
 | 
 | ||||||
|                 <!-- What to expect --> |                         <!-- Features --> | ||||||
|                         <div class="features"> |                         <div class="features"> | ||||||
|                             <h3>What to expect from us:</h3> |                             <h3>What to expect from us:</h3> | ||||||
|                             <div class="feature-grid"> |                             <div class="feature-grid"> | ||||||
|  | @ -428,7 +374,7 @@ | ||||||
|                         <div class="unsubscribe"> |                         <div class="unsubscribe"> | ||||||
|                             <p> |                             <p> | ||||||
|                                 <a href="{{ unsubscribe_link }}">Unsubscribe</a> |  |                                 <a href="{{ unsubscribe_link }}">Unsubscribe</a> |  | ||||||
|                         <a href="mailto:support@rideaware.com">Contact Support</a> | |                                 <a href="mailto:support@rideaware.com">Contact Support</a> | ||||||
|                             </p> |                             </p> | ||||||
|                             <p style="font-size: 12px; color: #6b7280; margin-top: 10px;"> |                             <p style="font-size: 12px; color: #6b7280; margin-top: 10px;"> | ||||||
|                                 © 2025 RideAware. All rights reserved. |                                 © 2025 RideAware. All rights reserved. | ||||||
|  | @ -439,6 +385,8 @@ | ||||||
|                         </div> |                         </div> | ||||||
|                     </div> |                     </div> | ||||||
|                 </div> |                 </div> | ||||||
|     </div> |             </td> | ||||||
|  |         </tr> | ||||||
|  |     </table> | ||||||
| </body> | </body> | ||||||
| </html> | </html> | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Cipher Vance
						Cipher Vance