Compare commits

..

No commits in common. "e3c005aa92ecf41aab5e55431972af36e652ec70" and "2a2df9f6e5d62aa5d71081bc8dbe1c21d60c8cb0" have entirely different histories.

6 changed files with 362 additions and 445 deletions

View file

@ -1,213 +1,64 @@
import os import os
import psycopg2 import psycopg2
from psycopg2 import pool, IntegrityError, OperationalError from psycopg2 import pool, IntegrityError
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")
) except Exception as e:
logger.info("Database connection pool initialized successfully") logging.error(f"Error creating connection pool: {e}")
raise
return _connection_pool
except Exception as e: def get_connection():
logger.error(f"Error creating connection pool: {e}") """Get a connection from the pool"""
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()
yield conn if conn.closed:
# 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:
logger.error(f"Database connection error: {e}") logging.error(f"Error getting connection from pool: {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 (legacy interface)""" """Return a connection to the pool"""
try: try:
pool = get_connection_pool() pool = get_connection_pool()
pool.putconn(conn) pool.putconn(conn)
except Exception as e: except Exception as e:
logger.error(f"Error returning connection to pool: {e}") logging.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: _connection_pool.closeall()
try: _connection_pool = None
_connection_pool.closeall() logging.info("All database connections closed")
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"""
@ -233,108 +84,138 @@ def index_exists(cursor, index_name):
def init_db(): def init_db():
"""Initialize database tables and indexes""" """Initialize database tables and indexes"""
with get_db_connection() as conn: conn = None
try: try:
with conn.cursor() as cursor: conn = get_connection()
# Create subscribers table cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS subscribers (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL
)
""")
# Add created_at column if it doesn't exist # Create subscribers table
if not column_exists(cursor, 'subscribers', 'created_at'): cursor.execute("""
cursor.execute(""" CREATE TABLE IF NOT EXISTS subscribers (
ALTER TABLE subscribers id SERIAL PRIMARY KEY,
ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP email TEXT UNIQUE NOT NULL
""") )
logger.info("Added created_at column to subscribers table") """)
# Create newsletters 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 newsletters( cursor.execute("""
id SERIAL PRIMARY KEY, ALTER TABLE subscribers
subject TEXT NOT NULL, ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
body TEXT NOT NULL, """)
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP logging.info("Added created_at column to subscribers table")
)
""")
# Create indexes only if they don't exist # Create newsletters table
indexes = [ cursor.execute("""
("idx_newsletters_sent_at", "CREATE INDEX IF NOT EXISTS idx_newsletters_sent_at ON newsletters(sent_at DESC)"), CREATE TABLE IF NOT EXISTS newsletters(
("idx_subscribers_email", "CREATE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email)"), id SERIAL PRIMARY KEY,
("idx_subscribers_created_at", "CREATE INDEX IF NOT EXISTS idx_subscribers_created_at ON subscribers(created_at DESC)") subject TEXT NOT NULL,
] body TEXT NOT NULL,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
for index_name, create_sql in indexes: # Create indexes only if they don't exist
cursor.execute(create_sql) if not index_exists(cursor, 'idx_newsletters_sent_at'):
logger.info(f"Ensured index {index_name} exists") cursor.execute("CREATE INDEX idx_newsletters_sent_at ON newsletters(sent_at DESC)")
logging.info("Created index idx_newsletters_sent_at")
conn.commit() if not index_exists(cursor, 'idx_subscribers_email'):
logger.info("Database tables and indexes initialized successfully") cursor.execute("CREATE INDEX idx_subscribers_email ON subscribers(email)")
logging.info("Created index idx_subscribers_email")
except Exception as e: if not index_exists(cursor, 'idx_subscribers_created_at'):
logger.error(f"Error initializing database: {e}") cursor.execute("CREATE INDEX idx_subscribers_created_at ON subscribers(created_at DESC)")
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 robust connection handling""" """Add email to subscribers with connection pooling"""
with get_db_connection() as conn: conn = None
try: try:
with conn.cursor() as cursor: conn = get_connection()
cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) cursor = conn.cursor()
conn.commit() cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,))
logger.info(f"Email added successfully: {email}") conn.commit()
return True cursor.close()
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()
logger.info(f"Email already exists: {email}") logging.info(f"Email already exists: {email}")
return False return False
except Exception as e: except Exception as e:
if conn:
conn.rollback() conn.rollback()
logger.error(f"Error adding email {email}: {e}") logging.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 robust connection handling""" """Remove email from subscribers with connection pooling"""
with get_db_connection() as conn: conn = None
try: try:
with conn.cursor() as cursor: conn = get_connection()
cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) cursor = conn.cursor()
conn.commit() cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,))
rows_affected = cursor.rowcount conn.commit()
rows_affected = cursor.rowcount
cursor.close()
if rows_affected > 0: if rows_affected > 0:
logger.info(f"Email removed successfully: {email}") logging.info(f"Email removed successfully: {email}")
return True return True
else: else:
logger.info(f"Email not found for removal: {email}") logging.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 return False
except Exception as e:
if conn:
conn.rollback()
logging.error(f"Error removing email {email}: {e}")
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"""
with get_db_connection() as conn: conn = None
try: try:
with conn.cursor() as cursor: conn = get_connection()
cursor.execute("SELECT COUNT(*) FROM subscribers") cursor = conn.cursor()
count = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM subscribers")
return count count = cursor.fetchone()[0]
cursor.close()
return count
except Exception as e: except Exception as e:
logger.error(f"Error getting subscriber count: {e}") logging.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,5 +1,4 @@
gunicorn gunicorn
flask
python-dotenv python-dotenv
psycopg2-binary psycopg2-binary
Flask
Flask-Limiter

View file

@ -5,8 +5,6 @@ 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
@ -19,19 +17,8 @@ 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( logging.basicConfig(level=logging.INFO)
level=logging.INFO,
format='%(asctime)s %(levelname)s [%(name)s] %(message)s'
)
# Cache configuration # Cache configuration
_newsletter_cache = {} _newsletter_cache = {}
@ -145,20 +132,24 @@ def after_request(response):
return response return response
def send_confirmation_email(to_address: str, html_body: str): def send_confirmation_email(to_address: str, unsubscribe_link: str):
""" """
Sends the HTML confirmation email to `to_address`. Sends the HTML confirmation email to `to_address`.
html_body is pre-rendered to avoid Flask context issues. This runs inside its own SMTP_SSL connection with reduced timeout.
""" """
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=10) as server: with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=5) 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())
@ -167,11 +158,11 @@ def send_confirmation_email(to_address: str, html_body: 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, html_body): def send_confirmation_async(email, unsubscribe_link):
""" """
Wrapper for threading.Thread target. Wrapper for threading.Thread target.
""" """
send_confirmation_email(email, html_body) send_confirmation_email(email, unsubscribe_link)
@app.route("/", methods=["GET"]) @app.route("/", methods=["GET"])
def index(): def index():
@ -179,7 +170,6 @@ 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 {}
@ -194,17 +184,12 @@ 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 with pre-rendered HTML # Start email sending in background thread
Thread( Thread(
target=send_confirmation_async, target=send_confirmation_async,
args=(email, html_body), args=(email, unsubscribe_link),
daemon=True daemon=True
).start() ).start()
@ -217,7 +202,6 @@ 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")
@ -236,7 +220,6 @@ 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.
@ -249,7 +232,6 @@ 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.
@ -266,7 +248,6 @@ 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:
@ -277,16 +258,10 @@ 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,7 +13,6 @@
</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 */
* { * {
@ -23,12 +22,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%;
} }
@ -41,41 +40,71 @@
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%;
} }
/* Email container */ /* Container */
.email-wrapper { .email-container {
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;
} }
/* Header styles */ .email-wrapper {
background-color: #f8fafc;
padding: 20px;
}
/* Header */
.header { .header {
background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 50%, #00d4ff 100%);
background-color: #1e4e9c; /* Fallback */
padding: 50px 30px; padding: 50px 30px;
text-align: center; text-align: center;
color: white; position: relative;
}
.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 {
font-size: 3rem; width: 80px;
margin-bottom: 20px; height: 80px;
display: block; background: rgba(255, 255, 255, 0.2);
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: block; display: inline-block;
} }
.logo-accent { .logo-accent {
@ -87,16 +116,19 @@
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;
} }
.subtitle { .header .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;
margin: 0; position: relative;
z-index: 1;
} }
/* Content styles */ /* Content */
.content { .content {
padding: 40px 30px; padding: 40px 30px;
text-align: center; text-align: center;
@ -119,7 +151,6 @@
/* 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;
@ -134,7 +165,10 @@
} }
.feature-grid { .feature-grid {
width: 100%; display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
margin-top: 20px;
} }
.feature-item { .feature-item {
@ -142,7 +176,6 @@
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);
} }
@ -165,7 +198,7 @@
line-height: 1.4; line-height: 1.4;
} }
/* CTA styles */ /* CTA Button */
.cta-section { .cta-section {
margin: 35px 0; margin: 35px 0;
padding: 25px; padding: 25px;
@ -174,22 +207,24 @@
.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%);
background-color: #1e4e9c; /* Fallback */ color: white;
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 {
background-color: #337cf2; transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(30, 78, 156, 0.4);
text-decoration: none; text-decoration: none;
color: white;
} }
/* Social section */ /* Social links */
.social-section { .social-section {
margin: 30px 0; margin: 30px 0;
padding: 25px; padding: 25px;
@ -205,7 +240,9 @@
} }
.social-links { .social-links {
text-align: center; display: flex;
justify-content: center;
gap: 15px;
} }
.social-link { .social-link {
@ -213,21 +250,22 @@
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%;
line-height: 40px; display: flex;
align-items: center;
justify-content: center;
font-size: 16px; font-size: 16px;
margin: 0 7px; transition: all 0.3s ease;
text-align: center;
} }
.social-link:hover { .social-link:hover {
background-color: #337cf2; transform: translateY(-2px) scale(1.05);
box-shadow: 0 5px 15px rgba(30, 78, 156, 0.3);
} }
/* Footer styles */ /* Footer */
.footer { .footer {
background: #1a1a1a; background: #1a1a1a;
color: white; color: white;
@ -239,8 +277,9 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.footer-content p { .footer p {
margin: 5px 0; margin: 5px 0;
opacity: 0.8;
font-size: 14px; font-size: 14px;
} }
@ -261,132 +300,145 @@
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 {
width: 100% !important; padding: 10px;
} }
.header { .header {
padding: 40px 20px !important; padding: 40px 20px;
} }
.header h1 { .header h1 {
font-size: 24px !important; font-size: 24px;
} }
.content { .content {
padding: 30px 20px !important; padding: 30px 20px;
} }
.features { .features {
padding: 25px 20px !important; padding: 25px 20px;
} }
.feature-item { .feature-grid {
margin-bottom: 10px !important; grid-template-columns: 1fr;
gap: 15px;
} }
.social-link { .social-links {
margin: 0 5px !important; gap: 10px;
}
.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;"> <div class="email-wrapper">
<tr> <div class="email-container">
<td align="center"> <!-- Header -->
<div class="email-wrapper"> <div class="header">
<!-- Header --> <div class="welcome-icon">🎉</div>
<div class="header"> <div class="logo">Ride<span class="logo-accent">Aware</span></div>
<div class="welcome-icon">🎉</div> <h1>Welcome Aboard!</h1>
<div class="logo">Ride<span class="logo-accent">Aware</span></div> <p class="subtitle">You're now part of the RideAware community</p>
<h1>Welcome Aboard!</h1> </div>
<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>
<!-- Features --> <!-- What to expect -->
<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">
<!-- CTA --> <span class="feature-icon">📊</span>
<div class="cta-section"> <div class="feature-title">Performance Insights</div>
<p style="margin-bottom: 20px;">Ready to start your journey with RideAware?</p> <div class="feature-desc">Data-driven analysis for better rides</div>
<a href="https://rideaware.com" target="_blank" class="cta-button">
Explore RideAware →
</a>
</div> </div>
<div class="feature-item">
<!-- Social section --> <span class="feature-icon">🆕</span>
<div class="social-section"> <div class="feature-title">Feature Updates</div>
<h4>Stay Connected</h4> <div class="feature-desc">Be first to know about new releases</div>
<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">
<p style="color: #6b7280; font-size: 14px; margin-top: 30px;"> <span class="feature-icon">👥</span>
We're excited to share our journey with you and help you achieve your cycling goals. Welcome to the RideAware family! 🚴‍♀️ <div class="feature-title">Community Stories</div>
</p> <div class="feature-desc">Inspiring journeys from fellow cyclists</div>
</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>
</tr> <!-- CTA -->
</table> <div class="cta-section">
<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>

View file

@ -7,8 +7,13 @@
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link rel="preconnect" href="https://cdn.statically.io" crossorigin> <link rel="preconnect" href="https://cdn.statically.io" crossorigin>
<link rel="stylesheet" href="{{ url_for('static', filename='css/newsletter_styles.css') }}"> <link rel="preload" as="style"
href="https://cdn.statically.io/gl/rideaware/landing/main/static/css/newsletter_styles.min.css"
onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet"
href="https://cdn.statically.io/gl/rideaware/landing/main/static/css/newsletter_styles.min.css">
</noscript>
</head> </head>
<body> <body>
<!-- Navigation --> <!-- Navigation -->

View file

@ -5,9 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RideAware - Newsletters</title> <title>RideAware - Newsletters</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/newsletter_styles.css') }}"> <link rel="preload" as="style"
href="https://cdn.statically.io/gl/rideaware/landing/main/static/css/newsletter_styles.min.css"
</head> onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet"
href="https://cdn.statically.io/gl/rideaware/landing/main/static/css/newsletter_styles.min.css">
</noscript>
`</head>
<body> <body>
<!-- Navigation --> <!-- Navigation -->
<nav class="navbar"> <nav class="navbar">