feat: enhance database reliability, add rate limiting, and improve email compatibility

This commit is contained in:
Cipher Vance 2025-08-31 09:48:10 -05:00
parent 2a2df9f6e5
commit 96f4243713
4 changed files with 441 additions and 348 deletions

View file

@ -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
if _connection_pool is None: with _pool_lock:
try: if _connection_pool is None:
_connection_pool = psycopg2.pool.ThreadedConnectionPool( try:
minconn=2, _connection_pool = SimpleRobustPool(
maxconn=20, minconn=int(os.getenv('DB_POOL_MIN', 3)),
host=os.getenv("PG_HOST"), maxconn=int(os.getenv('DB_POOL_MAX', 15)),
port=os.getenv("PG_PORT", 5432), host=os.getenv("PG_HOST"),
dbname=os.getenv("PG_DATABASE"), port=int(os.getenv("PG_PORT", 5432)),
user=os.getenv("PG_USER"), database=os.getenv("PG_DATABASE"),
password=os.getenv("PG_PASSWORD"), user=os.getenv("PG_USER"),
connect_timeout=5 password=os.getenv("PG_PASSWORD"),
) connect_timeout=int(os.getenv('DB_CONNECT_TIMEOUT', 10)),
logging.info("Database connection pool created successfully") application_name="rideaware_newsletter"
except Exception as e: )
logging.error(f"Error creating connection pool: {e}") logger.info("Database connection pool initialized successfully")
raise
return _connection_pool
def get_connection(): except Exception as e:
"""Get a connection from the pool""" logger.error(f"Error creating connection pool: {e}")
raise
return _connection_pool
@contextmanager
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
finally:
if conn:
try:
pool = get_connection_pool()
pool.putconn(conn)
except Exception as 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 raise
def return_connection(conn): def return_connection(conn):
"""Return a connection to the pool""" """Return a connection to the pool (legacy interface)"""
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 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
if _connection_pool: with _pool_lock:
_connection_pool.closeall() if _connection_pool:
_connection_pool = None try:
logging.info("All database connections closed") _connection_pool.closeall()
logger.info("All database connections closed")
except Exception as e:
logger.error(f"Error closing connections: {e}")
finally:
_connection_pool = None
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,138 +233,108 @@ 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
cursor.execute("""
CREATE TABLE IF NOT EXISTS subscribers (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL
)
""")
# Create subscribers table # Add created_at column if it doesn't exist
cursor.execute(""" if not column_exists(cursor, 'subscribers', 'created_at'):
CREATE TABLE IF NOT EXISTS subscribers ( cursor.execute("""
id SERIAL PRIMARY KEY, ALTER TABLE subscribers
email TEXT UNIQUE NOT NULL ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) """)
""") logger.info("Added created_at column to subscribers table")
# Add created_at column if it doesn't exist # Create newsletters table
if not column_exists(cursor, 'subscribers', 'created_at'): cursor.execute("""
cursor.execute(""" CREATE TABLE IF NOT EXISTS newsletters(
ALTER TABLE subscribers id SERIAL PRIMARY KEY,
ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP subject TEXT NOT NULL,
""") body TEXT NOT NULL,
logging.info("Added created_at column to subscribers table") sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create newsletters table # Create indexes only if they don't exist
cursor.execute(""" indexes = [
CREATE TABLE IF NOT EXISTS newsletters( ("idx_newsletters_sent_at", "CREATE INDEX IF NOT EXISTS idx_newsletters_sent_at ON newsletters(sent_at DESC)"),
id SERIAL PRIMARY KEY, ("idx_subscribers_email", "CREATE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email)"),
subject TEXT NOT NULL, ("idx_subscribers_created_at", "CREATE INDEX IF NOT EXISTS idx_subscribers_created_at ON subscribers(created_at DESC)")
body TEXT NOT NULL, ]
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create indexes only if they don't exist for index_name, create_sql in indexes:
if not index_exists(cursor, 'idx_newsletters_sent_at'): cursor.execute(create_sql)
cursor.execute("CREATE INDEX idx_newsletters_sent_at ON newsletters(sent_at DESC)") logger.info(f"Ensured index {index_name} exists")
logging.info("Created index idx_newsletters_sent_at")
if not index_exists(cursor, 'idx_subscribers_email'): conn.commit()
cursor.execute("CREATE INDEX idx_subscribers_email ON subscribers(email)") logger.info("Database tables and indexes initialized successfully")
logging.info("Created index idx_subscribers_email")
if not index_exists(cursor, 'idx_subscribers_created_at'): except Exception as e:
cursor.execute("CREATE INDEX idx_subscribers_created_at ON subscribers(created_at DESC)") logger.error(f"Error initializing database: {e}")
logging.info("Created index idx_subscribers_created_at")
conn.commit()
cursor.close()
logging.info("Database tables and indexes initialized successfully")
except Exception as e:
logging.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() logger.info(f"Email added successfully: {email}")
cursor.close() return True
logging.info(f"Email added successfully: {email}")
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
except Exception as e:
if conn:
conn.rollback()
logging.error(f"Error adding email {email}: {e}")
return False
finally:
if conn:
return_connection(conn)
def remove_email(email):
"""Remove email from subscribers with connection pooling"""
conn = None
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,))
conn.commit()
rows_affected = cursor.rowcount
cursor.close()
if rows_affected > 0:
logging.info(f"Email removed successfully: {email}")
return True
else:
logging.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 adding email {email}: {e}")
return False return False
finally: def remove_email(email):
if conn: """Remove email from subscribers with robust connection handling"""
return_connection(conn) with get_db_connection() as conn:
try:
with conn.cursor() as cursor:
cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,))
conn.commit()
rows_affected = cursor.rowcount
if rows_affected > 0:
logger.info(f"Email removed successfully: {email}")
return True
else:
logger.info(f"Email not found for removal: {email}")
return False
except Exception as e:
conn.rollback()
logger.error(f"Error removing email {email}: {e}")
return False
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] return count
cursor.close()
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

View file

@ -1,4 +1,5 @@
gunicorn gunicorn
flask
python-dotenv python-dotenv
psycopg2-binary psycopg2-binary
Flask
Flask-Limiter

View file

@ -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):

View file

@ -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,145 +261,132 @@
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>
<div class="email-wrapper"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f8fafc;">
<div class="email-container"> <tr>
<!-- Header --> <td align="center">
<div class="header"> <div class="email-wrapper">
<div class="welcome-icon">🎉</div> <!-- Header -->
<div class="logo">Ride<span class="logo-accent">Aware</span></div> <div class="header">
<h1>Welcome Aboard!</h1> <div class="welcome-icon">🎉</div>
<p class="subtitle">You're now part of the RideAware community</p> <div class="logo">Ride<span class="logo-accent">Aware</span></div>
</div> <h1>Welcome Aboard!</h1>
<p class="subtitle">You're now part of the RideAware community</p>
</div>
<!-- Content --> <!-- Content -->
<div class="content"> <div class="content">
<p class="main-message">Thanks for subscribing to RideAware newsletter!</p> <p class="main-message">Thanks for subscribing to RideAware newsletter!</p>
<p class="description"> <p class="description">
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">
<div class="feature-item"> <div class="feature-item">
<span class="feature-icon">🚴‍♂️</span> <span class="feature-icon">🚴‍♂️</span>
<div class="feature-title">Training Tips</div> <div class="feature-title">Training Tips</div>
<div class="feature-desc">Expert advice to improve your performance</div> <div class="feature-desc">Expert advice to improve your performance</div>
</div>
<div class="feature-item">
<span class="feature-icon">📊</span>
<div class="feature-title">Performance Insights</div>
<div class="feature-desc">Data-driven analysis for better rides</div>
</div>
<div class="feature-item">
<span class="feature-icon">🆕</span>
<div class="feature-title">Feature Updates</div>
<div class="feature-desc">Be first to know about new releases</div>
</div>
<div class="feature-item">
<span class="feature-icon">👥</span>
<div class="feature-title">Community Stories</div>
<div class="feature-desc">Inspiring journeys from fellow cyclists</div>
</div>
</div>
</div> </div>
<div class="feature-item">
<span class="feature-icon">📊</span> <!-- CTA -->
<div class="feature-title">Performance Insights</div> <div class="cta-section">
<div class="feature-desc">Data-driven analysis for better rides</div> <p style="margin-bottom: 20px;">Ready to start your journey with RideAware?</p>
<a href="https://rideaware.com" target="_blank" class="cta-button">
Explore RideAware →
</a>
</div> </div>
<div class="feature-item">
<span class="feature-icon">🆕</span> <!-- Social section -->
<div class="feature-title">Feature Updates</div> <div class="social-section">
<div class="feature-desc">Be first to know about new releases</div> <h4>Stay Connected</h4>
<div class="social-links">
<a href="#" class="social-link" title="Follow us on Twitter">🐦</a>
<a href="#" class="social-link" title="Like us on Facebook">📘</a>
<a href="#" class="social-link" title="Follow us on Instagram">📷</a>
</div>
</div> </div>
<div class="feature-item">
<span class="feature-icon">👥</span> <p style="color: #6b7280; font-size: 14px; margin-top: 30px;">
<div class="feature-title">Community Stories</div> We're excited to share our journey with you and help you achieve your cycling goals. Welcome to the RideAware family! 🚴‍♀️
<div class="feature-desc">Inspiring journeys from fellow cyclists</div> </p>
</div>
<!-- Footer -->
<div class="footer">
<div class="footer-content">
<p><strong>RideAware Team</strong></p>
<p>Empowering cyclists, one ride at a time</p>
</div>
<div class="unsubscribe">
<p>
<a href="{{ unsubscribe_link }}">Unsubscribe</a> |
<a href="mailto:support@rideaware.com">Contact Support</a>
</p>
<p style="font-size: 12px; color: #6b7280; margin-top: 10px;">
© 2025 RideAware. All rights reserved.
</p>
<p style="font-size: 11px; color: #9ca3af; margin-top: 8px;">
This email was sent to you because you subscribed to RideAware updates.
</p>
</div> </div>
</div> </div>
</div> </div>
</td>
<!-- CTA --> </tr>
<div class="cta-section"> </table>
<p style="margin-bottom: 20px;">Ready to start your journey with RideAware?</p>
<a href="https://rideaware.com" target="_blank" class="cta-button">
Explore RideAware →
</a>
</div>
<!-- Social section -->
<div class="social-section">
<h4>Stay Connected</h4>
<div class="social-links">
<a href="#" class="social-link" title="Follow us on Twitter">🐦</a>
<a href="#" class="social-link" title="Like us on Facebook">📘</a>
<a href="#" class="social-link" title="Follow us on Instagram">📷</a>
</div>
</div>
<p style="color: #6b7280; font-size: 14px; margin-top: 30px;">
We're excited to share our journey with you and help you achieve your cycling goals. Welcome to the RideAware family! 🚴‍♀️
</p>
</div>
<!-- Footer -->
<div class="footer">
<div class="footer-content">
<p><strong>RideAware Team</strong></p>
<p>Empowering cyclists, one ride at a time</p>
</div>
<div class="unsubscribe">
<p>
<a href="{{ unsubscribe_link }}">Unsubscribe</a> |
<a href="mailto:support@rideaware.com">Contact Support</a> |
</p>
<p style="font-size: 12px; color: #6b7280; margin-top: 10px;">
© 2025 RideAware. All rights reserved.
</p>
<p style="font-size: 11px; color: #9ca3af; margin-top: 8px;">
This email was sent to you because you subscribed to RideAware updates.
</p>
</div>
</div>
</div>
</div>
</body> </body>
</html> </html>