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
+
+
+
- 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 %}
+
+
+
+
+
- {% if emails %}
-
-
-
- | Email Address |
-
-
-
- {% for email in emails %}
-
- | {{ email }} |
-
- {% endfor %}
-
-
- {% else %}
-
No subscribers found.
- {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if subscribers %}
+
+
+
+
+ | Email Address |
+ Joined |
+ Source |
+ Actions |
+
+
+
+ {% for subscriber in subscribers %}
+
+ | {{ subscriber.email }} |
+ {{ subscriber.subscribed_at.strftime('%b %d, %Y') if subscriber.subscribed_at else 'N/A' }} |
+
+ {{ subscriber.source or 'manual' }}
+ |
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% if pagination.total_pages > 1 %}
+
+ {% endif %}
+ {% else %}
+
+
+
No subscribers yet
+
Start building your subscriber list!
+
+ {% endif %}
+
+
+
+
+
+
+
+ {% if recent_newsletters %}
+
+ {% for newsletter in recent_newsletters %}
+
+
{{ newsletter.subject }}
+
+ {{ newsletter.sent_at.strftime('%b %d, %Y at %H:%M') if newsletter.sent_at else 'N/A' }}
+ {{ newsletter.sent_by or 'System' }}
+ {% if newsletter.success_count is not none %}
+ {{ newsletter.success_count }} sent
+ {% endif %}
+ {% if newsletter.failure_count and newsletter.failure_count > 0 %}
+ {{ newsletter.failure_count }} failed
+ {% endif %}
+
+
+ {% endfor %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+
-