diff --git a/app.py b/app.py index 8d6188e..cb006ca 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,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 flask import ( Flask, render_template, @@ -10,23 +13,35 @@ from flask import ( url_for, flash, session, + jsonify, + abort ) from dotenv import load_dotenv from werkzeug.security import check_password_hash -from functools import wraps # Import wraps -from database import get_connection, init_db, get_all_emails, get_admin, create_default_admin +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 +) load_dotenv() app = Flask(__name__) app.secret_key = os.getenv("SECRET_KEY") base_url = os.getenv("BASE_URL") -# SMTP settings (for sending update emails) +# 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) # Use SENDER_EMAIL +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( @@ -34,117 +49,366 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) -# Initialize the database and create default admin user if necessary. +# Initialize the database and create default admin user init_db() create_default_admin() -# Decorator for requiring login +# Security decorators def login_required(f): - @wraps(f) # Use wraps to preserve function metadata + @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")) 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_update_email(subject, body, email): - """Sends email, returns True on success, False on failure.""" +def send_single_email(email_data): + """Send a single email (for thread pool execution).""" + email, subject, body, newsletter_id = email_data try: - server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) - server.set_debuglevel(False) # Keep debug level at False for production + server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=15) server.login(SMTP_USER, SMTP_PASSWORD) + # Create message + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = f"{SENDER_NAME} <{SENDER_EMAIL}>" + msg['To'] = email + + # Create unsubscribe link unsub_link = f"https://{base_url}/unsubscribe?email={email}" - custom_body = ( - f"{body}

" - f"If you ever wish to unsubscribe, please click here" - ) + + # HTML body with unsubscribe link + html_body = f""" + + + {body} +
+

+ If you wish to unsubscribe, please click here +

+ + + """ - msg = MIMEText(custom_body, "html", "utf-8") - msg["Subject"] = subject - msg["From"] = SENDER_EMAIL # Use sender email - msg["To"] = email - - server.sendmail(SENDER_EMAIL, email, msg.as_string()) # Use sender email + # Attach HTML part + html_part = MIMEText(html_body, 'html') + msg.attach(html_part) + server.sendmail(SENDER_EMAIL, email, msg.as_string()) server.quit() - logger.info(f"Update email sent to: {email}") - return True + + # 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 except Exception as e: - logger.error(f"Failed to send email to {email}: {e}") - return False - - -def process_send_update_email(subject, body): - """Helper function to send an update email to all subscribers.""" - subscribers = get_all_emails() - if not subscribers: - return "No subscribers found." - try: - for email in subscribers: - if not send_update_email(subject, body, email): - return f"Failed to send to {email}" # Specific failure message - - # Log newsletter content for audit purposes - conn = get_connection() - cursor = conn.cursor() - cursor.execute( - "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", (subject, body) - ) - conn.commit() - cursor.close() - conn.close() - - return "Email has been sent to all subscribers." - except Exception as e: - logger.exception("Error processing sending updates") - return f"Failed to send email: {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 +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("/") @login_required def index(): - """Displays all subscriber emails""" - emails = get_all_emails() - return render_template("admin_index.html", emails=emails) + """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("/send_update", methods=["GET", "POST"]) +@app.route("/subscribers") @login_required -def send_update(): - """Display a form to send an update email; process submission on POST.""" - if request.method == "POST": - subject = request.form["subject"] - body = request.form["body"] - result_message = process_send_update_email(subject, body) - flash(result_message) - return redirect(url_for("send_update")) - return render_template("send_update.html") +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.""" + 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 + +@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 + +@app.route("/send_newsletter", methods=["GET", "POST"]) +@login_required +def send_newsletter(): + """Enhanced newsletter sending with preview and batch processing.""" + 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") + +@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") - password = request.form.get("password") + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + + if not username or not password: + flash("Username and password are required", "error") + return redirect(url_for("login")) + admin = get_admin(username) - if admin and check_password_hash(admin[1], password): + 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")) + session["username"] = username - flash("Logged in successfully", "success") + 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) return redirect(url_for("index")) else: - flash("Invalid username or password", "danger") + flash("Invalid username or password", "error") return redirect(url_for("login")) + return render_template("login.html") - @app.route("/logout") def logout(): - session.pop("username", None) - flash("Logged out successfully", "success") + session.clear() + flash("You have been logged out successfully", "info") 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(port=5001, debug=True) + app.run(host="0.0.0.0", port=5001, debug=True) \ No newline at end of file diff --git a/database.py b/database.py index b90e0ef..f0832c7 100644 --- a/database.py +++ b/database.py @@ -1,9 +1,11 @@ import os import logging import psycopg2 -from psycopg2 import IntegrityError +from psycopg2 import IntegrityError, pool from dotenv import load_dotenv from werkzeug.security import generate_password_hash +from contextlib import contextmanager +from datetime import datetime, timezone load_dotenv() @@ -13,191 +15,508 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +# Connection pool for better performance +connection_pool = None -def get_connection(): - """Return a new connection to the PostgreSQL database.""" +def init_connection_pool(): + """Initialize the connection pool.""" + global connection_pool try: - conn = psycopg2.connect( + 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"), - connect_timeout=10, ) - return conn + logger.info("Connection pool created successfully") except Exception as e: - logger.error(f"Database connection error: {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) + ) + return cursor.fetchone()[0] + +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.""" - conn = None + """Initialize the database tables with improved schema.""" try: - conn = get_connection() - cursor = conn.cursor() + with get_db_connection() as conn: + cursor = conn.cursor() - # Create subscribers table (if not exists) - cursor.execute( + # Create basic subscribers table (backwards compatible) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS subscribers ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL + ) """ - CREATE TABLE IF NOT EXISTS subscribers ( - id SERIAL PRIMARY KEY, - email TEXT UNIQUE NOT NULL ) - """ - ) - # Create admin_users table (if not exists) - cursor.execute( + # 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 TABLE IF NOT EXISTS admin_users ( - id SERIAL PRIMARY KEY, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL ) - """ - ) - # Newsletter storage - cursor.execute( + # 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 + ) """ - 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() - logger.info("Database initialized successfully.") + # 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}") - if conn: - conn.rollback() # Rollback if there's an error - raise - finally: - if conn: - cursor.close() - conn.close() - -def get_all_emails(): - """Return a list of all subscriber emails.""" +def get_subscriber_stats(): + """Get comprehensive subscriber statistics.""" try: - conn = get_connection() - cursor = conn.cursor() - cursor.execute("SELECT email FROM subscribers") - results = cursor.fetchall() - emails = [row[0] for row in results] - logger.debug(f"Retrieved emails: {emails}") - return emails + 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} + +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 [] - finally: - if conn: - cursor.close() - conn.close() + return {'subscribers': [], 'total_count': 0, 'page': 1, 'per_page': per_page, 'total_pages': 0} - -def add_email(email): +def add_email(email, source='manual'): """Insert an email into the subscribers table.""" - conn = None try: - conn = get_connection() - cursor = conn.cursor() - cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) - conn.commit() - logger.info(f"Email {email} added successfully.") - return True + 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 except IntegrityError: logger.warning(f"Attempted to add duplicate email: {email}") return False except Exception as e: logger.error(f"Error adding email {email}: {e}") return False - finally: - if conn: - cursor.close() - conn.close() - def remove_email(email): - """Remove an email from the subscribers table.""" - conn = None + """Mark email as unsubscribed or delete if status column doesn't exist.""" try: - conn = get_connection() - cursor = conn.cursor() - cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) - rowcount = cursor.rowcount - conn.commit() - logger.info(f"Email {email} removed successfully.") - return rowcount > 0 + 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 removing email {email}: {e}") + logger.error(f"Error {'unsubscribing' if has_status else 'removing'} email {email}: {e}") return False - finally: - if conn: - cursor.close() - conn.close() - def get_admin(username): - """Retrieve admin credentials for a given username. - Returns a tuple (username, password_hash) if found, otherwise None. - """ - conn = None + """Retrieve admin credentials and update last login.""" try: - conn = get_connection() - cursor = conn.cursor() - cursor.execute( - "SELECT username, password FROM admin_users WHERE username = %s", - (username,), - ) - result = cursor.fetchone() - return result # (username, password_hash) + 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}") return None - finally: - if conn: - cursor.close() - conn.close() - 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") - conn = None + try: - conn = get_connection() - cursor = conn.cursor() + with get_db_connection() as conn: + cursor = conn.cursor() - # Check if the admin already exists - cursor.execute( - "SELECT id FROM admin_users WHERE username = %s", (default_username,) - ) - if cursor.fetchone() is None: - cursor.execute( - "INSERT INTO admin_users (username, password) VALUES (%s, %s)", - (default_username, hashed_password), - ) - conn.commit() - logger.info("Default admin created successfully") - else: - logger.info("Default admin already exists") + # 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") + else: + logger.info("Admin users already exist") except Exception as e: logger.error(f"Error creating default admin: {e}") - if conn: - conn.rollback() - finally: - if conn: - cursor.close() - conn.close() +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 diff --git a/templates/admin_index.html b/templates/admin_index.html index c4620af..2c3869a 100644 --- a/templates/admin_index.html +++ b/templates/admin_index.html @@ -3,42 +3,866 @@ - Admin Center - Subscribers - + Newsletter Admin Dashboard + + +
+
+

+ + Newsletter Admin +

+ +
+
-

Subscribers

-

- Send Update Email| - Logout -

+
+ + {% 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 %} - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - {% endwith %} + +
+
+
+
+
{{ stats.total_active }}
+
Active Subscribers
+
+
+ +
+
+
- {% if emails %} - - - - - - - - {% for email in emails %} - - - - {% endfor %} - -
Email Address
{{ email }}
- {% else %} -

No subscribers found.

- {% endif %} +
+
+
+
{{ 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 %} +
+
+
+
+ + - + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html index 97fefdd..7325fb1 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,29 +1,383 @@ - - - - Admin Login - + + + Admin Login - Newsletter Admin + + - -

Admin Login

- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - {% endwith %} -
- - - - - -
- +
+
+ +

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 %} + +
+
+ +
+ + +
+
+ +
+ +
+ + + +
+
+ + +
+ +
+

Having trouble signing in? Contact your administrator.

+
+
+ + + + \ No newline at end of file diff --git a/templates/send_newsletter.html b/templates/send_newsletter.html new file mode 100644 index 0000000..7675756 --- /dev/null +++ b/templates/send_newsletter.html @@ -0,0 +1,667 @@ + + + + + + Send Newsletter - Newsletter Admin + + + + +
+
+

+ + Send Newsletter +

+ +
+
+ +
+ + {% 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 %} + +
+ +
+
+

+ + Compose Newsletter +

+
+
+
+
+ + +
+ +
+ + + +
+ + + + + +
+ + +
+ +
+ + +
+
+
+
+ + +
+ +
+
+

+ + Quick Stats +

+
+
+
+

Subscriber Information

+
+ Active Subscribers: + Loading... +
+
+ New This Month: + Loading... +
+
+ Total Sent: + Loading... +
+
+
+
+ + +
+

Writing Tips

+
    +
  • Keep subject lines under 50 characters
  • +
  • Use personalization when possible
  • +
  • Include a clear call-to-action
  • +
  • Test your content before sending
  • +
  • Mobile-friendly formatting is key
  • +
+
+ + + {% if preview %} +
+
+

+ + Email Preview +

+
+
+
+ {{ preview|safe }} +
+
+
+ {% endif %} +
+
+
+ +