diff --git a/Dockerfile b/Dockerfile index 5d0ac86..88ff350 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,30 @@ -FROM python:3.11-slim-buster +# Use an official Python runtime as a base +FROM python:3.11-slim-bookworm -# Install build dependencies (build-essential provides gcc and other tools) -RUN apt-get update && apt-get install -y build-essential +# Set working directory +WORKDIR /app -WORKDIR /rideaware_landing +# Install system dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* +# Copy requirements first to leverage Docker cache COPY requirements.txt . +# Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt +# Copy application code COPY . . -ENV FLASK_APP=server.py +# Environment variables +ENV FLASK_APP=app.py +ENV FLASK_ENV=production +ENV ENVIRONMENT=production EXPOSE 5001 -CMD ["gunicorn", "--bind", "0.0.0.0:5001", "app:app"] +# Use Gunicorn as production server +CMD ["gunicorn", "--bind", "0.0.0.0:5001", "--workers", "4", "--timeout", "120", "app:app"] \ No newline at end of file diff --git a/app.py b/app.py index cb006ca..85c05bd 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,9 @@ import os -import logging import smtplib from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -import concurrent.futures -from threading import Lock +from functools import wraps +from urllib.parse import urlparse, urljoin + from flask import ( Flask, render_template, @@ -13,402 +12,233 @@ from flask import ( url_for, flash, session, - jsonify, - abort ) +from markupsafe import escape from dotenv import load_dotenv from werkzeug.security import check_password_hash -from functools import wraps -import re + from database import ( - get_db_connection, init_db, get_all_emails, get_admin, create_default_admin, - get_subscriber_stats, add_email, remove_email, save_newsletter, - update_newsletter_stats, log_email_delivery, get_recent_newsletters + get_connection, + init_db, + get_all_emails, + get_admin, + create_default_admin, ) load_dotenv() + app = Flask(__name__) app.secret_key = os.getenv("SECRET_KEY") -base_url = os.getenv("BASE_URL") +base_url = os.getenv("BASE_URL", "").strip().strip("/") -# SMTP settings SMTP_SERVER = os.getenv("SMTP_SERVER") SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) SMTP_USER = os.getenv("SMTP_USER") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) -SENDER_NAME = os.getenv("SENDER_NAME", "Newsletter Admin") -# Email sending configuration -MAX_EMAIL_WORKERS = 5 -email_send_lock = Lock() - -# Logging setup -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) -logger = logging.getLogger(__name__) - -# Initialize the database and create default admin user init_db() create_default_admin() -# Security decorators + def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): if "username" not in session: - if request.is_json: - return jsonify({"error": "Authentication required"}), 401 - flash("Please log in to access this page.", "warning") - return redirect(url_for("login")) + next_url = request.full_path if request.query_string else request.path + return redirect(url_for("login", next=next_url)) return f(*args, **kwargs) + return decorated_function -def validate_email(email): - """Validate email format.""" - pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' - return re.match(pattern, email) is not None -def send_single_email(email_data): - """Send a single email (for thread pool execution).""" - email, subject, body, newsletter_id = email_data +def get_dashboard_counts(): + """Return dict of counts: total subscribers, total newsletters, sent today.""" + counts = {"total_subscribers": 0, "total_newsletters": 0, "sent_today": 0} try: - server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=15) - server.login(SMTP_USER, SMTP_PASSWORD) + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM subscribers") + counts["total_subscribers"] = cur.fetchone()[0] or 0 - # Create message - msg = MIMEMultipart('alternative') - msg['Subject'] = subject - msg['From'] = f"{SENDER_NAME} <{SENDER_EMAIL}>" - msg['To'] = email + cur.execute("SELECT COUNT(*) FROM newsletters") + counts["total_newsletters"] = cur.fetchone()[0] or 0 - # Create unsubscribe link + cur.execute( + """ + SELECT COUNT(*) + FROM newsletters + WHERE sent_at::date = CURRENT_DATE + """ + ) + counts["sent_today"] = cur.fetchone()[0] or 0 + + cur.close() + conn.close() + except Exception: + pass + return counts + + +def is_safe_url(target: str) -> bool: + if not target: + return False + ref = urlparse(request.host_url) + test = urlparse(urljoin(request.host_url, target)) + return test.scheme in ("http", "https") and ref.netloc == test.netloc + + +def send_update_email(subject: str, body_html: str, email: str) -> bool: + """Send a single HTML email with retries.""" + max_retries = 3 + retry_count = 0 + + unsub_link = "" + if base_url: unsub_link = f"https://{base_url}/unsubscribe?email={email}" - - # HTML body with unsubscribe link - html_body = f""" - - - {body} -
-

- If you wish to unsubscribe, please click here -

- - - """ - # Attach HTML part - html_part = MIMEText(html_body, 'html') - msg.attach(html_part) + if unsub_link: + custom_body = ( + f"{body_html}" + f"

" + f"If you ever wish to unsubscribe, please click " + f"here." + ) + else: + custom_body = body_html - server.sendmail(SENDER_EMAIL, email, msg.as_string()) - server.quit() - - # Log successful delivery - if newsletter_id: - log_email_delivery(newsletter_id, email, 'sent') - - logger.info(f"Email sent successfully to: {email}") - return True, email, None + while retry_count < max_retries: + try: + server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) + server.set_debuglevel(0) + server.login(SMTP_USER, SMTP_PASSWORD) + + msg = MIMEText(custom_body, "html", "utf-8") + msg["Subject"] = subject + msg["From"] = SENDER_EMAIL + msg["To"] = email + + server.sendmail(SENDER_EMAIL, [email], msg.as_string()) + server.quit() + return True + except Exception: + retry_count += 1 + if retry_count >= max_retries: + break + import time + + time.sleep(1.0) + + return False + + +def process_send_update_email(subject: str, body_html: str) -> str: + """Send update email to all subscribers and log newsletter content.""" + try: + subscribers = get_all_emails() + if not subscribers: + return "No subscribers found." + + failures = [] + for email in subscribers: + if not send_update_email(subject, body_html, email): + failures.append(email) + + try: + conn = get_connection() + cursor = conn.cursor() + cursor.execute( + "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", + (subject, body_html), + ) + conn.commit() + cursor.close() + conn.close() + except Exception: + pass + + if failures: + return f"Sent with failures: {len(failures)} recipients failed." + return "Email has been sent to all subscribers." except Exception as e: - error_msg = str(e) - logger.error(f"Failed to send email to {email}: {error_msg}") - - # Log failed delivery - if newsletter_id: - log_email_delivery(newsletter_id, email, 'failed', error_msg) - - return False, email, error_msg + return f"Failed to send email: {e}" -def send_newsletter_batch(subject, body, email_list, newsletter_id=None): - """Send newsletter to multiple recipients using thread pool.""" - success_count = 0 - failure_count = 0 - failed_emails = [] - - # Prepare email data for thread pool - email_data_list = [(email, subject, body, newsletter_id) for email in email_list] - - with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_EMAIL_WORKERS) as executor: - future_to_email = { - executor.submit(send_single_email, email_data): email_data[0] - for email_data in email_data_list - } - - for future in concurrent.futures.as_completed(future_to_email): - success, email, error = future.result() - if success: - success_count += 1 - else: - failure_count += 1 - failed_emails.append({'email': email, 'error': error}) - - return success_count, failure_count, failed_emails -@app.route("/") +@app.route("/", methods=["GET"]) @login_required def index(): - """Dashboard with subscriber statistics and recent activity.""" - page = request.args.get('page', 1, type=int) - search = request.args.get('search', '') - per_page = 25 - - stats = get_subscriber_stats() - subscribers_data = get_all_emails(page=page, per_page=per_page, search=search) - recent_newsletters = get_recent_newsletters(limit=5) - - return render_template( - "admin_index.html", - stats=stats, - subscribers=subscribers_data['subscribers'], - pagination={ - 'page': subscribers_data['page'], - 'per_page': subscribers_data['per_page'], - 'total_pages': subscribers_data['total_pages'], - 'total_count': subscribers_data['total_count'] - }, - search=search, - recent_newsletters=recent_newsletters - ) - -@app.route("/subscribers") -@login_required -def subscribers(): - """Detailed subscriber management page.""" - page = request.args.get('page', 1, type=int) - search = request.args.get('search', '') - per_page = 50 - - subscribers_data = get_all_emails(page=page, per_page=per_page, search=search) - - return render_template( - "subscribers.html", - subscribers=subscribers_data['subscribers'], - pagination={ - 'page': subscribers_data['page'], - 'per_page': subscribers_data['per_page'], - 'total_pages': subscribers_data['total_pages'], - 'total_count': subscribers_data['total_count'] - }, - search=search - ) - -@app.route("/add_subscriber", methods=["POST"]) -@login_required -def add_subscriber(): - """Add a new subscriber via AJAX.""" + """Dashboard: list subscriber emails and show widgets.""" + emails = [] try: - data = request.get_json() - email = data.get('email', '').strip().lower() - - if not email or not validate_email(email): - return jsonify({"success": False, "message": "Invalid email format"}), 400 - - success = add_email(email, source='admin_manual') - - if success: - return jsonify({"success": True, "message": f"Successfully added {email}"}) - else: - return jsonify({"success": False, "message": "Email already exists or failed to add"}), 400 - - except Exception as e: - logger.error(f"Error adding subscriber: {e}") - return jsonify({"success": False, "message": "Server error occurred"}), 500 + emails = get_all_emails() + except Exception: + flash("Could not load subscribers right now.", "danger") -@app.route("/remove_subscriber", methods=["POST"]) -@login_required -def remove_subscriber(): - """Remove/unsubscribe a subscriber via AJAX.""" - try: - data = request.get_json() - email = data.get('email', '').strip().lower() - - if not email: - return jsonify({"success": False, "message": "Email is required"}), 400 - - success = remove_email(email) - - if success: - return jsonify({"success": True, "message": f"Successfully unsubscribed {email}"}) - else: - return jsonify({"success": False, "message": "Email not found"}), 404 - - except Exception as e: - logger.error(f"Error removing subscriber: {e}") - return jsonify({"success": False, "message": "Server error occurred"}), 500 + counts = get_dashboard_counts() + return render_template("admin_index.html", emails=emails, counts=counts) -@app.route("/send_newsletter", methods=["GET", "POST"]) + +@app.route("/send_update", methods=["GET", "POST"]) @login_required -def send_newsletter(): - """Enhanced newsletter sending with preview and batch processing.""" +def send_update(): if request.method == "POST": - action = request.form.get('action', 'send') - subject = request.form.get('subject', '').strip() - body = request.form.get('body', '').strip() - - if not subject or not body: - flash("Subject and body are required", "error") - return redirect(url_for('send_newsletter')) - - if action == 'preview': - # Return preview - preview_html = f""" -
-

Subject: {subject}

-
{body}
-
-

Unsubscribe link will be automatically added to all emails

-
- """ - return render_template("send_newsletter.html", preview=preview_html, subject=subject, body=body) - - elif action == 'send': - # Get all active subscribers (backwards compatible) - try: - with get_db_connection() as conn: - cursor = conn.cursor() - - # Check if status column exists for backwards compatibility - cursor.execute( - """ - SELECT EXISTS ( - SELECT FROM information_schema.columns - WHERE table_name = 'subscribers' AND column_name = 'status' - ) - """ - ) - has_status = cursor.fetchone()[0] - - if has_status: - cursor.execute("SELECT email FROM subscribers WHERE status = 'active'") - else: - cursor.execute("SELECT email FROM subscribers") - - email_list = [row[0] for row in cursor.fetchall()] - - except Exception as e: - logger.error(f"Error fetching subscriber emails: {e}") - flash("Error retrieving subscriber list", "error") - return redirect(url_for('send_newsletter')) - - if not email_list: - flash("No active subscribers found", "warning") - return redirect(url_for('send_newsletter')) - - # Save newsletter to database - newsletter_id = save_newsletter(subject, body, session['username'], len(email_list)) - - # Send emails in batches - try: - success_count, failure_count, failed_emails = send_newsletter_batch( - subject, body, email_list, newsletter_id - ) - - # Update newsletter statistics - if newsletter_id: - update_newsletter_stats(newsletter_id, success_count, failure_count) - - # Flash results - if success_count > 0: - flash(f"Newsletter sent successfully to {success_count} subscribers!", "success") - - if failure_count > 0: - flash(f"Failed to send to {failure_count} subscribers", "error") - for failed in failed_emails[:5]: # Show first 5 failures - flash(f"Failed: {failed['email']} - {failed['error'][:100]}", "warning") - - except Exception as e: - logger.error(f"Error sending newsletter: {e}") - flash(f"Error sending newsletter: {str(e)}", "error") - - return redirect(url_for('send_newsletter')) - - return render_template("send_newsletter.html") + subject = (request.form.get("subject") or "").strip() + body_html = request.form.get("body") or "" + + if not subject or not body_html: + flash("Subject and body are required", "danger") + return redirect(url_for("send_update")) + + result_message = process_send_update_email(subject, body_html) + flash(escape(result_message)) + return redirect(url_for("send_update")) + + return render_template("send_update.html") -@app.route("/newsletter_history") -@login_required -def newsletter_history(): - """View newsletter sending history.""" - newsletters = get_recent_newsletters(limit=50) - return render_template("newsletter_history.html", newsletters=newsletters) @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": - username = request.form.get("username", "").strip() - password = request.form.get("password", "") - + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + if not username or not password: - flash("Username and password are required", "error") + flash("Username and password are required", "danger") return redirect(url_for("login")) - + admin = get_admin(username) - if admin and len(admin) >= 3 and check_password_hash(admin[2], password): - if len(admin) >= 4 and not admin[3]: # Check is_active - flash("Account is disabled", "error") - return redirect(url_for("login")) - + if admin and check_password_hash(admin[1], password): session["username"] = username - session["admin_id"] = admin[0] - flash("Logged in successfully!", "success") - - # Redirect to intended page or dashboard - next_page = request.args.get('next') - if next_page: - return redirect(next_page) + session.permanent = True + app.config["SESSION_COOKIE_HTTPONLY"] = True + app.config["SESSION_COOKIE_SECURE"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + + next_url = request.args.get("next") + if next_url and is_safe_url(next_url): + flash("Logged in successfully", "success") + return redirect(next_url) + + flash("Logged in successfully", "success") return redirect(url_for("index")) else: - flash("Invalid username or password", "error") + flash("Invalid username or password", "danger") return redirect(url_for("login")) - + return render_template("login.html") -@app.route("/logout") + +@app.route("/logout", methods=["GET"]) def logout(): - session.clear() - flash("You have been logged out successfully", "info") + session.pop("username", None) + flash("Logged out successfully", "success") return redirect(url_for("login")) -# Public unsubscribe endpoint -@app.route("/unsubscribe") -def unsubscribe(): - """Public unsubscribe endpoint.""" - email = request.args.get('email', '').strip().lower() - - if not email or not validate_email(email): - return render_template("unsubscribe.html", error="Invalid email address") - - success = remove_email(email) - - if success: - return render_template("unsubscribe.html", success=True, email=email) - else: - return render_template("unsubscribe.html", error="Email not found or already unsubscribed") - -# API endpoints for AJAX requests -@app.route("/api/stats") -@login_required -def api_stats(): - """API endpoint for dashboard statistics.""" - stats = get_subscriber_stats() - return jsonify(stats) - -# Error handlers -@app.errorhandler(404) -def not_found(error): - return render_template("error.html", error="Page not found", code=404), 404 - -@app.errorhandler(500) -def internal_error(error): - logger.error(f"Internal error: {error}") - return render_template("error.html", error="Internal server error", code=500), 500 - -# Context processors -@app.context_processor -def inject_user(): - return dict(current_user=session.get('username')) if __name__ == "__main__": - app.run(host="0.0.0.0", port=5001, debug=True) \ No newline at end of file + is_prod = os.getenv("ENVIRONMENT", "development").lower() == "production" + app.config["PREFERRED_URL_SCHEME"] = "https" if is_prod else "http" + if is_prod: + app.run(host="0.0.0.0", port=5001, debug=False, use_reloader=False) + else: + app.run(host="0.0.0.0", port=5001, debug=True, use_reloader=True) \ No newline at end of file diff --git a/database.py b/database.py index f0832c7..e5a85fb 100644 --- a/database.py +++ b/database.py @@ -1,522 +1,234 @@ import os -import logging import psycopg2 -from psycopg2 import IntegrityError, pool +from psycopg2 import IntegrityError, pool, OperationalError from dotenv import load_dotenv from werkzeug.security import generate_password_hash -from contextlib import contextmanager -from datetime import datetime, timezone load_dotenv() -# Logging setup -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) -logger = logging.getLogger(__name__) +try: + DB_MIN_CONN = int(os.getenv("DB_MIN_CONN", 1)) + DB_MAX_CONN = int(os.getenv("DB_MAX_CONN", 10)) -# Connection pool for better performance -connection_pool = None - -def init_connection_pool(): - """Initialize the connection pool.""" - global connection_pool - try: - connection_pool = psycopg2.pool.ThreadedConnectionPool( - 1, 20, # min and max connections - host=os.getenv("PG_HOST"), - port=os.getenv("PG_PORT"), - dbname=os.getenv("PG_DATABASE"), - user=os.getenv("PG_USER"), - password=os.getenv("PG_PASSWORD"), - ) - logger.info("Connection pool created successfully") - except Exception as e: - logger.error(f"Connection pool creation error: {e}") - raise - -@contextmanager -def get_db_connection(): - """Context manager for database connections.""" - if connection_pool is None: - init_connection_pool() - - conn = None - try: - conn = connection_pool.getconn() - yield conn - except Exception as e: - if conn: - conn.rollback() - logger.error(f"Database operation error: {e}") - raise - finally: - if conn: - connection_pool.putconn(conn) - -def check_column_exists(cursor, table_name, column_name): - """Check if a column exists in a table.""" - cursor.execute( - """ - SELECT EXISTS ( - SELECT FROM information_schema.columns - WHERE table_name = %s AND column_name = %s - ) - """, - (table_name, column_name) + conn_pool = pool.ThreadedConnectionPool( + minconn=DB_MIN_CONN, + maxconn=DB_MAX_CONN, + host=os.getenv("PG_HOST"), + port=os.getenv("PG_PORT"), + dbname=os.getenv("PG_DATABASE"), + user=os.getenv("PG_USER"), + password=os.getenv("PG_PASSWORD"), + connect_timeout=10, ) - return cursor.fetchone()[0] +except OperationalError: + raise +except Exception: + raise + + +def get_connection(): + """Get a connection from the connection pool.""" + return conn_pool.getconn() -def migrate_database(conn): - """Apply database migrations to upgrade existing schema.""" - cursor = conn.cursor() - - # Migration 1: Add new columns to subscribers table - if not check_column_exists(cursor, 'subscribers', 'subscribed_at'): - logger.info("Adding subscribed_at column to subscribers table") - cursor.execute( - "ALTER TABLE subscribers ADD COLUMN subscribed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP" - ) - - if not check_column_exists(cursor, 'subscribers', 'status'): - logger.info("Adding status column to subscribers table") - cursor.execute( - "ALTER TABLE subscribers ADD COLUMN status TEXT DEFAULT 'active'" - ) - # Add check constraint - cursor.execute( - "ALTER TABLE subscribers ADD CONSTRAINT subscribers_status_check CHECK (status IN ('active', 'unsubscribed'))" - ) - # Update existing rows - cursor.execute("UPDATE subscribers SET status = 'active' WHERE status IS NULL") - - if not check_column_exists(cursor, 'subscribers', 'source'): - logger.info("Adding source column to subscribers table") - cursor.execute( - "ALTER TABLE subscribers ADD COLUMN source TEXT DEFAULT 'manual'" - ) - - # Migration 2: Add new columns to admin_users table - if not check_column_exists(cursor, 'admin_users', 'created_at'): - logger.info("Adding created_at column to admin_users table") - cursor.execute( - "ALTER TABLE admin_users ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP" - ) - - if not check_column_exists(cursor, 'admin_users', 'last_login'): - logger.info("Adding last_login column to admin_users table") - cursor.execute( - "ALTER TABLE admin_users ADD COLUMN last_login TIMESTAMP WITH TIME ZONE" - ) - - if not check_column_exists(cursor, 'admin_users', 'is_active'): - logger.info("Adding is_active column to admin_users table") - cursor.execute( - "ALTER TABLE admin_users ADD COLUMN is_active BOOLEAN DEFAULT TRUE" - ) - - # Migration 3: Add new columns to newsletters table - if not check_column_exists(cursor, 'newsletters', 'sent_by'): - logger.info("Adding sent_by column to newsletters table") - cursor.execute( - "ALTER TABLE newsletters ADD COLUMN sent_by TEXT" - ) - - if not check_column_exists(cursor, 'newsletters', 'recipient_count'): - logger.info("Adding recipient_count column to newsletters table") - cursor.execute( - "ALTER TABLE newsletters ADD COLUMN recipient_count INTEGER DEFAULT 0" - ) - - if not check_column_exists(cursor, 'newsletters', 'success_count'): - logger.info("Adding success_count column to newsletters table") - cursor.execute( - "ALTER TABLE newsletters ADD COLUMN success_count INTEGER DEFAULT 0" - ) - - if not check_column_exists(cursor, 'newsletters', 'failure_count'): - logger.info("Adding failure_count column to newsletters table") - cursor.execute( - "ALTER TABLE newsletters ADD COLUMN failure_count INTEGER DEFAULT 0" - ) - - conn.commit() - logger.info("Database migrations completed successfully") def init_db(): - """Initialize the database tables with improved schema.""" + """Initialize database tables with connection pool.""" + conn = None + cursor = None try: - with get_db_connection() as conn: - cursor = conn.cursor() + conn = get_connection() + cursor = conn.cursor() - # Create basic subscribers table (backwards compatible) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS subscribers ( - id SERIAL PRIMARY KEY, - email TEXT UNIQUE NOT NULL - ) + cursor.execute( """ + CREATE TABLE IF NOT EXISTS subscribers ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL ) - - # Create basic admin_users table (backwards compatible) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS admin_users ( - id SERIAL PRIMARY KEY, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL - ) """ - ) + ) - # Create basic newsletters table (backwards compatible) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS newsletters ( - id SERIAL PRIMARY KEY, - subject TEXT NOT NULL, - body TEXT NOT NULL, - sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP - ) + cursor.execute( """ + CREATE TABLE IF NOT EXISTS admin_users ( + id SERIAL PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL ) - - # Email delivery tracking (new table) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS email_deliveries ( - id SERIAL PRIMARY KEY, - newsletter_id INTEGER REFERENCES newsletters(id), - email TEXT NOT NULL, - status TEXT CHECK (status IN ('sent', 'failed', 'bounced')), - sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - error_message TEXT - ) """ - ) + ) - conn.commit() - logger.info("Basic database tables created successfully") - - # Apply migrations to upgrade schema - migrate_database(conn) - - # Add indexes after migrations are complete - try: - cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscribers_status ON subscribers(status)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_newsletters_sent_at ON newsletters(sent_at)") - conn.commit() - logger.info("Database indexes created successfully") - except Exception as e: - logger.warning(f"Some indexes may not have been created: {e}") - # Continue anyway as this is not critical - - logger.info("Database initialization completed successfully") - - except Exception as e: - logger.error(f"Database initialization error: {e}") + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS newsletters ( + id SERIAL PRIMARY KEY, + subject TEXT NOT NULL, + body TEXT NOT NULL, + sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + conn.commit() + except Exception: + if conn: + conn.rollback() raise + finally: + if cursor: + cursor.close() + if conn: + conn_pool.putconn(conn) -def get_subscriber_stats(): - """Get comprehensive subscriber statistics.""" + +def get_all_emails(): + """Return a list of all subscriber emails.""" + conn = None + cursor = None try: - with get_db_connection() as conn: - cursor = conn.cursor() - - # Check if status column exists - has_status = check_column_exists(cursor, 'subscribers', 'status') - - if has_status: - # Get total subscribers with status filtering - cursor.execute("SELECT COUNT(*) FROM subscribers WHERE status = 'active'") - total_active = cursor.fetchone()[0] - - cursor.execute("SELECT COUNT(*) FROM subscribers WHERE status = 'unsubscribed'") - total_unsubscribed = cursor.fetchone()[0] - - # Get recent signups (last 30 days) - check if subscribed_at exists - has_subscribed_at = check_column_exists(cursor, 'subscribers', 'subscribed_at') - if has_subscribed_at: - cursor.execute(""" - SELECT COUNT(*) FROM subscribers - WHERE subscribed_at >= NOW() - INTERVAL '30 days' AND status = 'active' - """) - recent_signups = cursor.fetchone()[0] - else: - recent_signups = 0 - else: - # Fallback for old schema - cursor.execute("SELECT COUNT(*) FROM subscribers") - total_active = cursor.fetchone()[0] - total_unsubscribed = 0 - recent_signups = 0 - - # Get newsletters sent - cursor.execute("SELECT COUNT(*) FROM newsletters") - newsletters_sent = cursor.fetchone()[0] - - return { - 'total_active': total_active, - 'total_unsubscribed': total_unsubscribed, - 'recent_signups': recent_signups, - 'newsletters_sent': newsletters_sent - } - except Exception as e: - logger.error(f"Error retrieving subscriber stats: {e}") - return {'total_active': 0, 'total_unsubscribed': 0, 'recent_signups': 0, 'newsletters_sent': 0} + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT email FROM subscribers") + results = cursor.fetchall() + return [row[0] for row in results] + except Exception: + return [] + finally: + if cursor: + cursor.close() + if conn: + conn_pool.putconn(conn) -def get_all_emails(page=1, per_page=50, search=''): - """Return paginated list of subscriber emails with search functionality.""" - try: - with get_db_connection() as conn: - cursor = conn.cursor() - - # Check which columns exist - has_status = check_column_exists(cursor, 'subscribers', 'status') - has_subscribed_at = check_column_exists(cursor, 'subscribers', 'subscribed_at') - has_source = check_column_exists(cursor, 'subscribers', 'source') - - # Calculate offset - offset = (page - 1) * per_page - - # Build base query based on available columns - if has_status: - base_query = "FROM subscribers WHERE status = 'active'" - else: - base_query = "FROM subscribers WHERE 1=1" - - params = [] - - if search: - base_query += " AND email ILIKE %s" - params.append(f"%{search}%") - - # Get total count - cursor.execute(f"SELECT COUNT(*) {base_query}", params) - total_count = cursor.fetchone()[0] - - # Build select query based on available columns - select_fields = ["id", "email"] - if has_subscribed_at: - select_fields.append("subscribed_at") - if has_source: - select_fields.append("source") - - # Get paginated results - query = f""" - SELECT {', '.join(select_fields)} - {base_query} - ORDER BY {"subscribed_at DESC" if has_subscribed_at else "id DESC"} - LIMIT %s OFFSET %s - """ - params.extend([per_page, offset]) - cursor.execute(query, params) - - results = cursor.fetchall() - subscribers = [] - - for row in results: - subscriber = { - 'id': row[0], - 'email': row[1], - 'subscribed_at': row[2] if has_subscribed_at and len(row) > 2 else None, - 'source': row[3] if has_source and len(row) > 3 else 'manual' - } - subscribers.append(subscriber) - - return { - 'subscribers': subscribers, - 'total_count': total_count, - 'page': page, - 'per_page': per_page, - 'total_pages': (total_count + per_page - 1) // per_page - } - except Exception as e: - logger.error(f"Error retrieving emails: {e}") - return {'subscribers': [], 'total_count': 0, 'page': 1, 'per_page': per_page, 'total_pages': 0} -def add_email(email, source='manual'): +def add_email(email): """Insert an email into the subscribers table.""" + conn = None + cursor = None try: - with get_db_connection() as conn: - cursor = conn.cursor() - - # Check if source column exists - has_source = check_column_exists(cursor, 'subscribers', 'source') - - if has_source: - cursor.execute( - "INSERT INTO subscribers (email, source) VALUES (%s, %s)", - (email.lower().strip(), source) - ) - else: - cursor.execute( - "INSERT INTO subscribers (email) VALUES (%s)", - (email.lower().strip(),) - ) - - conn.commit() - logger.info(f"Email {email} added successfully.") - return True + conn = get_connection() + cursor = conn.cursor() + cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) + conn.commit() + return True except IntegrityError: - logger.warning(f"Attempted to add duplicate email: {email}") + if conn: + conn.rollback() return False - except Exception as e: - logger.error(f"Error adding email {email}: {e}") + except Exception: + if conn: + conn.rollback() return False + finally: + if cursor: + cursor.close() + if conn: + conn_pool.putconn(conn) + def remove_email(email): - """Mark email as unsubscribed or delete if status column doesn't exist.""" + """Remove an email from the subscribers table.""" + conn = None + cursor = None try: - with get_db_connection() as conn: - cursor = conn.cursor() - - # Check if status column exists - has_status = check_column_exists(cursor, 'subscribers', 'status') - - if has_status: - # Mark as unsubscribed - cursor.execute( - "UPDATE subscribers SET status = 'unsubscribed' WHERE email = %s", - (email,) - ) - else: - # Delete the record (old behavior) - cursor.execute( - "DELETE FROM subscribers WHERE email = %s", - (email,) - ) - - rowcount = cursor.rowcount - conn.commit() - logger.info(f"Email {email} {'unsubscribed' if has_status else 'removed'} successfully.") - return rowcount > 0 - except Exception as e: - logger.error(f"Error {'unsubscribing' if has_status else 'removing'} email {email}: {e}") + conn = get_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) + rowcount = cursor.rowcount + conn.commit() + return rowcount > 0 + except Exception: + if conn: + conn.rollback() return False + finally: + if cursor: + cursor.close() + if conn: + conn_pool.putconn(conn) + def get_admin(username): - """Retrieve admin credentials and update last login.""" + """Retrieve admin credentials for a given username. + Returns a tuple (username, password_hash) if found, otherwise None. + """ + conn = None + cursor = None try: - with get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute( - """SELECT id, username, password, is_active - FROM admin_users - WHERE username = %s AND is_active = TRUE""", - (username,), - ) - result = cursor.fetchone() - - if result: - # Update last login - cursor.execute( - "UPDATE admin_users SET last_login = %s WHERE id = %s", - (datetime.now(timezone.utc), result[0]) - ) - conn.commit() - - return result - except Exception as e: - logger.error(f"Error retrieving admin: {e}") + conn = get_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT username, password FROM admin_users WHERE username = %s", + (username,), + ) + return cursor.fetchone() + except Exception: return None + finally: + if cursor: + cursor.close() + if conn: + conn_pool.putconn(conn) + def create_default_admin(): """Create a default admin user if one doesn't already exist.""" default_username = os.getenv("ADMIN_USERNAME", "admin") default_password = os.getenv("ADMIN_PASSWORD", "changeme") hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256") - - try: - with get_db_connection() as conn: - cursor = conn.cursor() - # Check if any admin exists - cursor.execute("SELECT COUNT(*) FROM admin_users WHERE is_active = TRUE") - admin_count = cursor.fetchone()[0] - - if admin_count == 0: - cursor.execute( - "INSERT INTO admin_users (username, password) VALUES (%s, %s)", - (default_username, hashed_password), - ) - conn.commit() - logger.info("Default admin created successfully") + conn = None + cursor = None + try: + conn = get_connection() + cursor = conn.cursor() + + cursor.execute( + "SELECT id FROM admin_users WHERE username = %s", (default_username,) + ) + exists = cursor.fetchone() + if exists is None: + cursor.execute( + "INSERT INTO admin_users (username, password) VALUES (%s, %s)", + (default_username, hashed_password), + ) + conn.commit() + except Exception: + if conn: + conn.rollback() + finally: + if cursor: + cursor.close() + if conn: + conn_pool.putconn(conn) + + +def close_pool(): + """Close the database connection pool.""" + try: + conn_pool.closeall() + except Exception: + pass + + +class DatabaseContext: + """Optional context manager for manual transactions.""" + def __init__(self): + self.conn = None + self.cursor = None + + def __enter__(self): + self.conn = get_connection() + self.cursor = self.conn.cursor() + return self.cursor + + def __exit__(self, exc_type, exc_val, exc_tb): + try: + if exc_type: + self.conn.rollback() else: - logger.info("Admin users already exist") - except Exception as e: - logger.error(f"Error creating default admin: {e}") - -def save_newsletter(subject, body, sent_by, recipient_count=0): - """Save newsletter to database and return the ID.""" - try: - with get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute( - """INSERT INTO newsletters (subject, body, sent_by, recipient_count) - VALUES (%s, %s, %s, %s) RETURNING id""", - (subject, body, sent_by, recipient_count) - ) - newsletter_id = cursor.fetchone()[0] - conn.commit() - return newsletter_id - except Exception as e: - logger.error(f"Error saving newsletter: {e}") - return None - -def update_newsletter_stats(newsletter_id, success_count, failure_count): - """Update newsletter delivery statistics.""" - try: - with get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute( - """UPDATE newsletters - SET success_count = %s, failure_count = %s - WHERE id = %s""", - (success_count, failure_count, newsletter_id) - ) - conn.commit() - except Exception as e: - logger.error(f"Error updating newsletter stats: {e}") - -def log_email_delivery(newsletter_id, email, status, error_message=None): - """Log individual email delivery attempt.""" - try: - with get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute( - """INSERT INTO email_deliveries (newsletter_id, email, status, error_message) - VALUES (%s, %s, %s, %s)""", - (newsletter_id, email, status, error_message) - ) - conn.commit() - except Exception as e: - logger.error(f"Error logging email delivery: {e}") - -def get_recent_newsletters(limit=10): - """Get recent newsletters with statistics.""" - try: - with get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute( - """SELECT id, subject, sent_at, sent_by, recipient_count, success_count, failure_count - FROM newsletters - ORDER BY sent_at DESC - LIMIT %s""", - (limit,) - ) - results = cursor.fetchall() - return [{ - 'id': row[0], - 'subject': row[1], - 'sent_at': row[2], - 'sent_by': row[3], - 'recipient_count': row[4], - 'success_count': row[5], - 'failure_count': row[6] - } for row in results] - except Exception as e: - logger.error(f"Error retrieving recent newsletters: {e}") - return [] \ No newline at end of file + self.conn.commit() + finally: + if self.cursor: + self.cursor.close() + if self.conn: + conn_pool.putconn(self.conn) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 26fcaf5..3d2a2e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,6 @@ flask python-dotenv Werkzeug psycopg2-binary +psycopg2-pool +python-decouple +markupsafe \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index b9c3f88..9dca122 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,55 +1,306 @@ +:root { + --primary: #2563eb; + --primary-hover: #1d4ed8; + --bg: #f5f7fb; + --bg-grad-1: #f8fbff; + --bg-grad-2: #eef3fb; + --surface: #ffffff; + --text: #0f172a; + --muted: #64748b; + --border: #e5e7eb; + --ring: rgba(37, 99, 235, 0.25); + --radius: 14px; + --shadow-1: 0 8px 24px rgba(15, 23, 42, 0.08); + --shadow-2: 0 14px 38px rgba(15, 23, 42, 0.12); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} +html, body { + margin: 0; + padding: 0; +} body { - font-family: Arial, sans-serif; - padding: 20px; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; + color: var(--text); + background: radial-gradient(1000px 650px at 0% 0%, var(--bg-grad-1), transparent 60%), + radial-gradient(900px 600px at 100% 0%, var(--bg-grad-2), transparent 55%), + var(--bg); + min-height: 100vh; +} + +.navbar { + position: sticky; + top: 0; + z-index: 1000; + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(15, 23, 42, 0.06); +} +.navbar-content { + max-width: 1100px; + margin: 0 auto; + padding: 14px 20px; + display: flex; + align-items: center; + justify-content: space-between; +} +.brand { + color: var(--text); + font-weight: 700; + text-decoration: none; + letter-spacing: 0.2px; +} +.navbar-links a { + color: var(--muted); + text-decoration: none; + margin-left: 14px; + padding: 8px 12px; + border-radius: 10px; + transition: all 0.2s ease; +} +.navbar-links a:hover { + color: var(--text); + background: rgba(15, 23, 42, 0.05); +} +.navbar-links .logout { + color: #b91c1c; +} +.navbar-links .logout:hover { + background: rgba(185, 28, 28, 0.08); +} + +.container { + max-width: 1100px; + margin: 28px auto 48px; + padding: 0 20px; + padding-bottom: 64px; +} + +.footer { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + border-top: 1px solid rgba(15, 23, 42, 0.06); + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(8px); +} + +.footer-inner { + max-width: 1100px; + margin: 0 auto; + padding: 14px 20px; + color: var(--muted); + font-size: 14px; +} + +.page-header { + margin-bottom: 18px; + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.page-title { + margin: 0 0 4px 0; + font-size: 26px; + font-weight: 700; +} +.page-subtitle { + margin: 0; + color: var(--muted); + font-size: 14px; +} +.page-actions { + display: flex; + gap: 10px; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-1); + padding: 20px; +} +.empty-state { + text-align: center; + color: var(--muted); +} + +.widgets { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 18px; +} +.widget-card { + background: linear-gradient(180deg, #fff, #fafcff); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-1); + padding: 18px; +} +.widget-label { + color: var(--muted); + font-weight: 600; + font-size: 13px; + margin-bottom: 8px; +} +.widget-value { + font-size: 28px; + font-weight: 800; + letter-spacing: 0.3px; + color: var(--text); +} + +.table-wrap { + overflow: hidden; + border-radius: 12px; + border: 1px solid var(--border); +} +.table { + width: 100%; + border-collapse: collapse; + background: transparent; +} +.table thead th { + text-align: left; + font-weight: 600; + color: var(--muted); + font-size: 13px; + letter-spacing: 0.3px; + background: #f9fafb; + padding: 12px 14px; + border-bottom: 1px solid var(--border); +} +.table tbody td { + padding: 14px; + border-bottom: 1px solid #f1f5f9; +} +.table tbody tr:hover td { + background: #f9fbff; + transition: background 0.15s ease; +} + +.form { + display: grid; + gap: 16px; +} +.form-group { + display: grid; + gap: 8px; +} +.form-group label { + font-weight: 600; + color: #334155; + font-size: 14px; +} +.form-group input, +.form-group textarea { + width: 100%; + color: var(--text); + background: #ffffff; + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 14px; + font-size: 15px; + transition: box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease; +} +.form-group textarea { + resize: vertical; + min-height: 160px; +} +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #a7c2ff; + box-shadow: 0 0 0 4px var(--ring); + background: #ffffff; +} +.form-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.button { + appearance: none; + border: 1px solid var(--border); + background: #ffffff; + color: var(--text); + border-radius: 12px; + padding: 10px 14px; + cursor: pointer; + font-weight: 600; + transition: transform 0.05s ease, background 0.2s ease, border 0.2s ease; +} +.button:hover { + background: #f5f7fb; +} +.button:active { + transform: translateY(1px); +} +.button-primary { + color: #ffffff; + background: linear-gradient(180deg, #3b82f6, var(--primary)); + border-color: rgba(37, 99, 235, 0.4); +} +.button-primary:hover { + background: linear-gradient(180deg, #2f74ed, var(--primary-hover)); +} +.button-secondary { + background: #ffffff; +} + +.flash-stack { + display: grid; + gap: 10px; + margin-bottom: 18px; +} +.flash { + border-radius: 12px; + padding: 12px 14px; + font-weight: 600; + border: 1px solid var(--border); + background: #ffffff; +} +.flash-success { + border-color: #a7f3d0; + background: #ecfdf5; + color: #065f46; +} +.flash-danger, +.flash-error { + border-color: #fecaca; + background: #fef2f2; + color: #991b1b; +} +.flash-warning { + border-color: #fde68a; + background: #fffbeb; + color: #92400e; +} + +.auth-wrapper { + display: grid; + place-items: center; + min-height: calc(100vh - 120px); + padding-top: 40px; +} +.auth-card { + max-width: 420px; + width: 100%; +} + +@media (max-width: 900px) { + .widgets { + grid-template-columns: 1fr; } - - table { - border-collapse: collapse; - width: 100%; - } - - th, - td { - border: 1px solid #ddd; - padding: 8px; - text-align: left; - } - - th { - background-color: #f2f2f2; - } - - a { - margin-right: 10px; - } - - form { - max-width: 600px; - margin: 0 auto; - } - - label { - display: block; - margin-top: 15px; - } - - input[type="text"], - input[type="password"], - textarea { - width: 100%; - padding: 8px; - } - - button { - margin-top: 15px; - padding: 10px 20px; - } - - .flash { - background-color: #f8d7da; - color: #721c24; - padding: 10px; - margin-bottom: 10px; - text-align: center; - } - \ No newline at end of file +} \ No newline at end of file diff --git a/templates/admin_index.html b/templates/admin_index.html index 2c3869a..bf57258 100644 --- a/templates/admin_index.html +++ b/templates/admin_index.html @@ -1,868 +1,53 @@ - - - - - - Newsletter Admin Dashboard - - - - -
-
-

- - Newsletter Admin -

- -
-
- -
- - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {% if category == 'success' %} - - {% elif category == 'error' %} - - {% elif category == 'warning' %} - - {% else %} - - {% endif %} - {{ message }} -
- {% endfor %} -
- {% endif %} - {% endwith %} - - -
-
-
-
-
{{ stats.total_active }}
-
Active Subscribers
-
-
- -
-
-
- -
-
-
-
{{ stats.recent_signups }}
-
New This Month
-
-
- -
-
-
- -
-
-
-
{{ stats.newsletters_sent }}
-
Newsletters Sent
-
-
- -
-
-
- -
-
-
-
{{ stats.total_unsubscribed }}
-
Unsubscribed
-
-
- -
-
-
-
- - -
- -
-
-

- - Recent Subscribers -

- - View All - -
-
- -
- - -
- - - - - {% if subscribers %} -
- - - - - - - - - - - {% for subscriber in subscribers %} - - - - - - - {% endfor %} - -
Email AddressJoinedSourceActions
{{ subscriber.email }}{{ subscriber.subscribed_at.strftime('%b %d, %Y') if subscriber.subscribed_at else 'N/A' }} - {{ subscriber.source or 'manual' }} - - -
-
- - - {% if pagination.total_pages > 1 %} - - {% endif %} - {% else %} -
- -

No subscribers yet

-

Start building your subscriber list!

-
- {% endif %} -
-
- - -
-
-

- - Recent Newsletters -

- - New - -
-
- {% if recent_newsletters %} -
- {% for newsletter in recent_newsletters %} - - {% endfor %} -
- {% else %} -
- -

No newsletters sent yet

-

Send your first newsletter to get started!

- - Create Newsletter - -
- {% endif %} -
-
-
+{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} +{% block content %} + - - - \ No newline at end of file + {% if emails %} +
+
+ + + + + + + + {% for email in emails %} + + + + {% endfor %} + +
Email Address
{{ email }}
+
+
+ {% else %} +
+

No subscribers found.

+
+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..13df2b0 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,58 @@ + + + + + + {% block title %}Admin{% endblock %} + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + + + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html index 7325fb1..f70fb06 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,383 +1,34 @@ - - - - - - Admin Login - Newsletter Admin - - - - -
-