refactor: init overhaul of admin panel

This commit is contained in:
Cipher Vance 2025-08-25 21:32:45 -05:00
parent 9d78f1fdb4
commit 69468dc5bf
6 changed files with 2686 additions and 295 deletions

412
app.py
View file

@ -2,6 +2,9 @@ import os
import logging import logging
import smtplib import smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import concurrent.futures
from threading import Lock
from flask import ( from flask import (
Flask, Flask,
render_template, render_template,
@ -10,23 +13,35 @@ from flask import (
url_for, url_for,
flash, flash,
session, session,
jsonify,
abort
) )
from dotenv import load_dotenv from dotenv import load_dotenv
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from functools import wraps # Import wraps from functools import wraps
from database import get_connection, init_db, get_all_emails, get_admin, create_default_admin 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() load_dotenv()
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.getenv("SECRET_KEY") app.secret_key = os.getenv("SECRET_KEY")
base_url = os.getenv("BASE_URL") base_url = os.getenv("BASE_URL")
# SMTP settings (for sending update emails) # SMTP settings
SMTP_SERVER = os.getenv("SMTP_SERVER") SMTP_SERVER = os.getenv("SMTP_SERVER")
SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
SMTP_USER = os.getenv("SMTP_USER") SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") 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 setup
logging.basicConfig( logging.basicConfig(
@ -34,117 +49,366 @@ logging.basicConfig(
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Initialize the database and create default admin user if necessary. # Initialize the database and create default admin user
init_db() init_db()
create_default_admin() create_default_admin()
# Decorator for requiring login # Security decorators
def login_required(f): def login_required(f):
@wraps(f) # Use wraps to preserve function metadata @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if "username" not in session: 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 redirect(url_for("login"))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function 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): def send_single_email(email_data):
"""Sends email, returns True on success, False on failure.""" """Send a single email (for thread pool execution)."""
email, subject, body, newsletter_id = email_data
try: try:
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=15)
server.set_debuglevel(False) # Keep debug level at False for production
server.login(SMTP_USER, SMTP_PASSWORD) 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}" unsub_link = f"https://{base_url}/unsubscribe?email={email}"
custom_body = (
f"{body}<br><br>" # HTML body with unsubscribe link
f"If you ever wish to unsubscribe, please click <a href='{unsub_link}'>here</a>" html_body = f"""
) <html>
<body>
{body}
<hr style="margin-top: 40px; border: none; border-top: 1px solid #eee;">
<p style="font-size: 12px; color: #888; text-align: center;">
If you wish to unsubscribe, please <a href="{unsub_link}" style="color: #888;">click here</a>
</p>
</body>
</html>
"""
msg = MIMEText(custom_body, "html", "utf-8") # Attach HTML part
msg["Subject"] = subject html_part = MIMEText(html_body, 'html')
msg["From"] = SENDER_EMAIL # Use sender email msg.attach(html_part)
msg["To"] = email
server.sendmail(SENDER_EMAIL, email, msg.as_string()) # Use sender email
server.sendmail(SENDER_EMAIL, email, msg.as_string())
server.quit() 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: except Exception as e:
logger.error(f"Failed to send email to {email}: {e}") error_msg = str(e)
return False logger.error(f"Failed to send email to {email}: {error_msg}")
# Log failed delivery
def process_send_update_email(subject, body): if newsletter_id:
"""Helper function to send an update email to all subscribers.""" log_email_delivery(newsletter_id, email, 'failed', error_msg)
subscribers = get_all_emails()
if not subscribers: return False, email, error_msg
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}"
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("/")
@login_required @login_required
def index(): def index():
"""Displays all subscriber emails""" """Dashboard with subscriber statistics and recent activity."""
emails = get_all_emails() page = request.args.get('page', 1, type=int)
return render_template("admin_index.html", emails=emails) 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 @login_required
def send_update(): def subscribers():
"""Display a form to send an update email; process submission on POST.""" """Detailed subscriber management page."""
if request.method == "POST": page = request.args.get('page', 1, type=int)
subject = request.form["subject"] search = request.args.get('search', '')
body = request.form["body"] per_page = 50
result_message = process_send_update_email(subject, body)
flash(result_message) subscribers_data = get_all_emails(page=page, per_page=per_page, search=search)
return redirect(url_for("send_update"))
return render_template("send_update.html") 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"""
<div class="email-preview">
<h3>Subject: {subject}</h3>
<div class="email-body">{body}</div>
<hr>
<p><small>Unsubscribe link will be automatically added to all emails</small></p>
</div>
"""
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"]) @app.route("/login", methods=["GET", "POST"])
def login(): def login():
if request.method == "POST": if request.method == "POST":
username = request.form.get("username") username = request.form.get("username", "").strip()
password = request.form.get("password") 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) 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 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")) return redirect(url_for("index"))
else: else:
flash("Invalid username or password", "danger") flash("Invalid username or password", "error")
return redirect(url_for("login")) return redirect(url_for("login"))
return render_template("login.html") return render_template("login.html")
@app.route("/logout") @app.route("/logout")
def logout(): def logout():
session.pop("username", None) session.clear()
flash("Logged out successfully", "success") flash("You have been logged out successfully", "info")
return redirect(url_for("login")) 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__": if __name__ == "__main__":
app.run(port=5001, debug=True) app.run(host="0.0.0.0", port=5001, debug=True)

View file

@ -1,9 +1,11 @@
import os import os
import logging import logging
import psycopg2 import psycopg2
from psycopg2 import IntegrityError from psycopg2 import IntegrityError, pool
from dotenv import load_dotenv from dotenv import load_dotenv
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from contextlib import contextmanager
from datetime import datetime, timezone
load_dotenv() load_dotenv()
@ -13,191 +15,508 @@ logging.basicConfig(
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Connection pool for better performance
connection_pool = None
def get_connection(): def init_connection_pool():
"""Return a new connection to the PostgreSQL database.""" """Initialize the connection pool."""
global connection_pool
try: try:
conn = psycopg2.connect( connection_pool = psycopg2.pool.ThreadedConnectionPool(
1, 20, # min and max connections
host=os.getenv("PG_HOST"), host=os.getenv("PG_HOST"),
port=os.getenv("PG_PORT"), port=os.getenv("PG_PORT"),
dbname=os.getenv("PG_DATABASE"), dbname=os.getenv("PG_DATABASE"),
user=os.getenv("PG_USER"), user=os.getenv("PG_USER"),
password=os.getenv("PG_PASSWORD"), password=os.getenv("PG_PASSWORD"),
connect_timeout=10,
) )
return conn logger.info("Connection pool created successfully")
except Exception as e: except Exception as e:
logger.error(f"Database connection error: {e}") logger.error(f"Connection pool creation error: {e}")
raise 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(): def init_db():
"""Initialize the database tables.""" """Initialize the database tables with improved schema."""
conn = None
try: try:
conn = get_connection() with get_db_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Create subscribers table (if not exists) # Create basic subscribers table (backwards compatible)
cursor.execute( 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) # Create basic admin_users table (backwards compatible)
cursor.execute( 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 # Create basic newsletters table (backwards compatible)
cursor.execute( 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() # Email delivery tracking (new table)
logger.info("Database initialized successfully.") 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: except Exception as e:
logger.error(f"Database initialization error: {e}") logger.error(f"Database initialization error: {e}")
if conn:
conn.rollback() # Rollback if there's an error
raise raise
finally:
if conn:
cursor.close()
conn.close()
def get_subscriber_stats():
def get_all_emails(): """Get comprehensive subscriber statistics."""
"""Return a list of all subscriber emails."""
try: try:
conn = get_connection() with get_db_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT email FROM subscribers")
results = cursor.fetchall() # Check if status column exists
emails = [row[0] for row in results] has_status = check_column_exists(cursor, 'subscribers', 'status')
logger.debug(f"Retrieved emails: {emails}")
return emails 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: except Exception as e:
logger.error(f"Error retrieving emails: {e}") logger.error(f"Error retrieving emails: {e}")
return [] return {'subscribers': [], 'total_count': 0, 'page': 1, 'per_page': per_page, 'total_pages': 0}
finally:
if conn:
cursor.close()
conn.close()
def add_email(email, source='manual'):
def add_email(email):
"""Insert an email into the subscribers table.""" """Insert an email into the subscribers table."""
conn = None
try: try:
conn = get_connection() with get_db_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,))
conn.commit() # Check if source column exists
logger.info(f"Email {email} added successfully.") has_source = check_column_exists(cursor, 'subscribers', 'source')
return True
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: except IntegrityError:
logger.warning(f"Attempted to add duplicate email: {email}") logger.warning(f"Attempted to add duplicate email: {email}")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Error adding email {email}: {e}") logger.error(f"Error adding email {email}: {e}")
return False return False
finally:
if conn:
cursor.close()
conn.close()
def remove_email(email): def remove_email(email):
"""Remove an email from the subscribers table.""" """Mark email as unsubscribed or delete if status column doesn't exist."""
conn = None
try: try:
conn = get_connection() with get_db_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,))
rowcount = cursor.rowcount # Check if status column exists
conn.commit() has_status = check_column_exists(cursor, 'subscribers', 'status')
logger.info(f"Email {email} removed successfully.")
return rowcount > 0 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: 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 return False
finally:
if conn:
cursor.close()
conn.close()
def get_admin(username): def get_admin(username):
"""Retrieve admin credentials for a given username. """Retrieve admin credentials and update last login."""
Returns a tuple (username, password_hash) if found, otherwise None.
"""
conn = None
try: try:
conn = get_connection() with get_db_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
"SELECT username, password FROM admin_users WHERE username = %s", """SELECT id, username, password, is_active
(username,), FROM admin_users
) WHERE username = %s AND is_active = TRUE""",
result = cursor.fetchone() (username,),
return result # (username, password_hash) )
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: except Exception as e:
logger.error(f"Error retrieving admin: {e}") logger.error(f"Error retrieving admin: {e}")
return None return None
finally:
if conn:
cursor.close()
conn.close()
def create_default_admin(): def create_default_admin():
"""Create a default admin user if one doesn't already exist.""" """Create a default admin user if one doesn't already exist."""
default_username = os.getenv("ADMIN_USERNAME", "admin") default_username = os.getenv("ADMIN_USERNAME", "admin")
default_password = os.getenv("ADMIN_PASSWORD", "changeme") default_password = os.getenv("ADMIN_PASSWORD", "changeme")
hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256") hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256")
conn = None
try: try:
conn = get_connection() with get_db_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Check if the admin already exists # Check if any admin exists
cursor.execute( cursor.execute("SELECT COUNT(*) FROM admin_users WHERE is_active = TRUE")
"SELECT id FROM admin_users WHERE username = %s", (default_username,) admin_count = cursor.fetchone()[0]
)
if cursor.fetchone() is None: if admin_count == 0:
cursor.execute( cursor.execute(
"INSERT INTO admin_users (username, password) VALUES (%s, %s)", "INSERT INTO admin_users (username, password) VALUES (%s, %s)",
(default_username, hashed_password), (default_username, hashed_password),
) )
conn.commit() conn.commit()
logger.info("Default admin created successfully") logger.info("Default admin created successfully")
else: else:
logger.info("Default admin already exists") logger.info("Admin users already exist")
except Exception as e: except Exception as e:
logger.error(f"Error creating default admin: {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 []

View file

@ -3,42 +3,866 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Center - Subscribers</title> <title>Newsletter Admin Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #667eea;
--primary-dark: #5a67d8;
--secondary: #764ba2;
--success: #48bb78;
--warning: #ed8936;
--error: #f56565;
--info: #4299e1;
--dark: #2d3748;
--light: #f7fafc;
--gray-100: #f7fafc;
--gray-200: #edf2f7;
--gray-300: #e2e8f0;
--gray-400: #cbd5e0;
--gray-500: #a0aec0;
--gray-600: #718096;
--gray-700: #4a5568;
--gray-800: #2d3748;
--gray-900: #1a202c;
--white: #ffffff;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--border-radius: 8px;
--border-radius-lg: 12px;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, var(--gray-100) 0%, var(--gray-200) 100%);
color: var(--gray-800);
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
padding: 2rem 0;
margin-bottom: 2rem;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-lg);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 2rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.75rem;
}
.nav-links {
display: flex;
gap: 1.5rem;
align-items: center;
}
.nav-links a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-links a:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--white);
padding: 2rem;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow);
transition: all 0.3s ease;
border-left: 4px solid var(--primary);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.stat-card.success { border-left-color: var(--success); }
.stat-card.warning { border-left-color: var(--warning); }
.stat-card.info { border-left-color: var(--info); }
.stat-card.error { border-left-color: var(--error); }
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.stat-icon {
width: 3rem;
height: 3rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
color: white;
}
.stat-icon.primary { background: var(--primary); }
.stat-icon.success { background: var(--success); }
.stat-icon.warning { background: var(--warning); }
.stat-icon.info { background: var(--info); }
.stat-number {
font-size: 2.5rem;
font-weight: 700;
color: var(--gray-800);
}
.stat-label {
color: var(--gray-600);
font-size: 0.875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.main-content {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.card {
background: var(--white);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.card-header {
padding: 1.5rem;
border-bottom: 1px solid var(--gray-200);
display: flex;
justify-content: between;
align-items: center;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--gray-800);
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-body {
padding: 1.5rem;
}
.search-box {
position: relative;
margin-bottom: 1.5rem;
}
.search-input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 2.5rem;
border: 2px solid var(--gray-300);
border-radius: var(--border-radius);
font-size: 0.875rem;
transition: all 0.3s ease;
}
.search-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.search-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--gray-400);
}
.table-container {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--gray-200);
}
.table th {
background: var(--gray-50);
font-weight: 600;
color: var(--gray-700);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table tbody tr:hover {
background: var(--gray-50);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--border-radius);
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
justify-content: center;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-success {
background: var(--success);
color: white;
}
.btn-warning {
background: var(--warning);
color: white;
}
.btn-error {
background: var(--error);
color: white;
}
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.75rem;
}
.flash-messages {
margin-bottom: 1.5rem;
}
.flash {
padding: 1rem;
border-radius: var(--border-radius);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.flash.success {
background: #f0fff4;
color: #22543d;
border: 1px solid #c6f6d5;
}
.flash.error {
background: #fff5f5;
color: #742a2a;
border: 1px solid #fed7d7;
}
.flash.warning {
background: #fffbeb;
color: #744210;
border: 1px solid #feebc8;
}
.flash.info {
background: #ebf8ff;
color: #2a4a5a;
border: 1px solid #bee3f8;
}
.recent-newsletters {
max-height: 400px;
overflow-y: auto;
}
.newsletter-item {
padding: 1rem;
border-bottom: 1px solid var(--gray-200);
transition: all 0.3s ease;
}
.newsletter-item:hover {
background: var(--gray-50);
}
.newsletter-item:last-child {
border-bottom: none;
}
.newsletter-subject {
font-weight: 600;
color: var(--gray-800);
margin-bottom: 0.25rem;
}
.newsletter-meta {
font-size: 0.75rem;
color: var(--gray-500);
display: flex;
gap: 1rem;
}
.pagination {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 1.5rem;
}
.pagination a,
.pagination span {
padding: 0.5rem 0.75rem;
border: 1px solid var(--gray-300);
border-radius: var(--border-radius);
color: var(--gray-600);
text-decoration: none;
transition: all 0.3s ease;
}
.pagination a:hover {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.pagination .current {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.add-subscriber {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.add-subscriber input {
flex: 1;
padding: 0.75rem;
border: 2px solid var(--gray-300);
border-radius: var(--border-radius);
transition: all 0.3s ease;
}
.add-subscriber input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.header-content {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.nav-links {
flex-wrap: wrap;
justify-content: center;
}
.main-content {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
.loading {
display: none;
text-align: center;
padding: 2rem;
}
.spinner {
display: inline-block;
width: 2rem;
height: 2rem;
border: 3px solid var(--gray-300);
border-top: 3px solid var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge.success {
background: #f0fff4;
color: #22543d;
}
.badge.error {
background: #fff5f5;
color: #742a2a;
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--gray-500);
}
.empty-state i {
font-size: 3rem;
margin-bottom: 1rem;
color: var(--gray-400);
}
</style>
</head> </head>
<body> <body>
<header class="header">
<div class="header-content">
<h1>
<i class="fas fa-envelope"></i>
Newsletter Admin
</h1>
<nav class="nav-links">
<a href="/"><i class="fas fa-tachometer-alt"></i> Dashboard</a>
<a href="/subscribers"><i class="fas fa-users"></i> Subscribers</a>
<a href="/send_newsletter"><i class="fas fa-paper-plane"></i> Send Newsletter</a>
<a href="/newsletter_history"><i class="fas fa-history"></i> History</a>
<a href="/logout"><i class="fas fa-sign-out-alt"></i> Logout</a>
</nav>
</div>
</header>
<h1>Subscribers</h1> <div class="container">
<p> <!-- Flash Messages -->
<a href="{{ url_for('send_update') }}">Send Update Email</a>| {% with messages = get_flashed_messages(with_categories=true) %}
<a href="{{ url_for('logout') }}">Logout</a> {% if messages %}
</p> <div class="flash-messages">
{% for category, message in messages %}
<div class="flash {{ category }}">
{% if category == 'success' %}
<i class="fas fa-check-circle"></i>
{% elif category == 'error' %}
<i class="fas fa-exclamation-circle"></i>
{% elif category == 'warning' %}
<i class="fas fa-exclamation-triangle"></i>
{% else %}
<i class="fas fa-info-circle"></i>
{% endif %}
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% with messages = get_flashed_messages(with_categories=true) %} <!-- Statistics Cards -->
{% if messages %} <div class="stats-grid">
{% for category, message in messages %} <div class="stat-card success">
<div class="flash">{{ message }}</div> <div class="stat-header">
{% endfor %} <div>
{% endif %} <div class="stat-number">{{ stats.total_active }}</div>
{% endwith %} <div class="stat-label">Active Subscribers</div>
</div>
<div class="stat-icon success">
<i class="fas fa-users"></i>
</div>
</div>
</div>
{% if emails %} <div class="stat-card info">
<table> <div class="stat-header">
<thead> <div>
<tr> <div class="stat-number">{{ stats.recent_signups }}</div>
<th>Email Address</th> <div class="stat-label">New This Month</div>
</tr> </div>
</thead> <div class="stat-icon info">
<tbody> <i class="fas fa-user-plus"></i>
{% for email in emails %} </div>
<tr> </div>
<td>{{ email }}</td> </div>
</tr>
{% endfor %} <div class="stat-card primary">
</tbody> <div class="stat-header">
</table> <div>
{% else %} <div class="stat-number">{{ stats.newsletters_sent }}</div>
<p>No subscribers found.</p> <div class="stat-label">Newsletters Sent</div>
{% endif %} </div>
<div class="stat-icon primary">
<i class="fas fa-paper-plane"></i>
</div>
</div>
</div>
<div class="stat-card warning">
<div class="stat-header">
<div>
<div class="stat-number">{{ stats.total_unsubscribed }}</div>
<div class="stat-label">Unsubscribed</div>
</div>
<div class="stat-icon warning">
<i class="fas fa-user-minus"></i>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="main-content">
<!-- Recent Subscribers -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<i class="fas fa-users"></i>
Recent Subscribers
</h2>
<a href="/subscribers" class="btn btn-primary btn-small">
<i class="fas fa-eye"></i> View All
</a>
</div>
<div class="card-body">
<!-- Add Subscriber Form -->
<div class="add-subscriber">
<input type="email" id="newEmail" placeholder="Add new subscriber email..." />
<button onclick="addSubscriber()" class="btn btn-success">
<i class="fas fa-plus"></i> Add
</button>
</div>
<!-- Search Box -->
<div class="search-box">
<i class="fas fa-search search-icon"></i>
<input type="text" class="search-input" placeholder="Search subscribers..."
value="{{ search }}" onkeyup="filterSubscribers(this.value)">
</div>
{% if subscribers %}
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Email Address</th>
<th>Joined</th>
<th>Source</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for subscriber in subscribers %}
<tr>
<td>{{ subscriber.email }}</td>
<td>{{ subscriber.subscribed_at.strftime('%b %d, %Y') if subscriber.subscribed_at else 'N/A' }}</td>
<td>
<span class="badge success">{{ subscriber.source or 'manual' }}</span>
</td>
<td>
<button onclick="removeSubscriber('{{ subscriber.email }}')"
class="btn btn-error btn-small">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.total_pages > 1 %}
<div class="pagination">
{% if pagination.page > 1 %}
<a href="?page={{ pagination.page - 1 }}{% if search %}&search={{ search }}{% endif %}">
<i class="fas fa-chevron-left"></i> Previous
</a>
{% endif %}
{% for page_num in range(1, pagination.total_pages + 1) %}
{% if page_num == pagination.page %}
<span class="current">{{ page_num }}</span>
{% else %}
<a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}">{{ page_num }}</a>
{% endif %}
{% endfor %}
{% if pagination.page < pagination.total_pages %}
<a href="?page={{ pagination.page + 1 }}{% if search %}&search={{ search }}{% endif %}">
Next <i class="fas fa-chevron-right"></i>
</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="empty-state">
<i class="fas fa-users"></i>
<h3>No subscribers yet</h3>
<p>Start building your subscriber list!</p>
</div>
{% endif %}
</div>
</div>
<!-- Recent Newsletters -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<i class="fas fa-history"></i>
Recent Newsletters
</h2>
<a href="/send_newsletter" class="btn btn-primary btn-small">
<i class="fas fa-plus"></i> New
</a>
</div>
<div class="card-body">
{% if recent_newsletters %}
<div class="recent-newsletters">
{% for newsletter in recent_newsletters %}
<div class="newsletter-item">
<div class="newsletter-subject">{{ newsletter.subject }}</div>
<div class="newsletter-meta">
<span><i class="fas fa-calendar"></i> {{ newsletter.sent_at.strftime('%b %d, %Y at %H:%M') if newsletter.sent_at else 'N/A' }}</span>
<span><i class="fas fa-user"></i> {{ newsletter.sent_by or 'System' }}</span>
{% if newsletter.success_count is not none %}
<span><i class="fas fa-check"></i> {{ newsletter.success_count }} sent</span>
{% endif %}
{% if newsletter.failure_count and newsletter.failure_count > 0 %}
<span><i class="fas fa-times"></i> {{ newsletter.failure_count }} failed</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<i class="fas fa-paper-plane"></i>
<h3>No newsletters sent yet</h3>
<p>Send your first newsletter to get started!</p>
<a href="/send_newsletter" class="btn btn-primary">
<i class="fas fa-plus"></i> Create Newsletter
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<script>
async function addSubscriber() {
const emailInput = document.getElementById('newEmail');
const email = emailInput.value.trim();
if (!email) {
alert('Please enter an email address');
return;
}
if (!isValidEmail(email)) {
alert('Please enter a valid email address');
return;
}
try {
const response = await fetch('/add_subscriber', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: email })
});
const result = await response.json();
if (result.success) {
showFlash('success', result.message);
emailInput.value = '';
setTimeout(() => location.reload(), 1000);
} else {
showFlash('error', result.message);
}
} catch (error) {
showFlash('error', 'An error occurred while adding the subscriber');
console.error('Error:', error);
}
}
async function removeSubscriber(email) {
if (!confirm(`Are you sure you want to unsubscribe ${email}?`)) {
return;
}
try {
const response = await fetch('/remove_subscriber', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: email })
});
const result = await response.json();
if (result.success) {
showFlash('success', result.message);
setTimeout(() => location.reload(), 1000);
} else {
showFlash('error', result.message);
}
} catch (error) {
showFlash('error', 'An error occurred while removing the subscriber');
console.error('Error:', error);
}
}
function filterSubscribers(searchTerm) {
if (searchTerm.length > 2 || searchTerm.length === 0) {
const url = new URL(window.location);
if (searchTerm) {
url.searchParams.set('search', searchTerm);
} else {
url.searchParams.delete('search');
}
url.searchParams.set('page', '1');
// Debounce the search
clearTimeout(window.searchTimeout);
window.searchTimeout = setTimeout(() => {
window.location = url;
}, 500);
}
}
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function showFlash(type, message) {
const flashContainer = document.querySelector('.flash-messages') || createFlashContainer();
const flash = document.createElement('div');
flash.className = `flash ${type}`;
const icon = type === 'success' ? 'check-circle' :
type === 'error' ? 'exclamation-circle' :
type === 'warning' ? 'exclamation-triangle' : 'info-circle';
flash.innerHTML = `<i class="fas fa-${icon}"></i> ${message}`;
flashContainer.appendChild(flash);
setTimeout(() => {
flash.remove();
}, 5000);
}
function createFlashContainer() {
const container = document.createElement('div');
container.className = 'flash-messages';
document.querySelector('.container').insertBefore(container, document.querySelector('.stats-grid'));
return container;
}
// Handle Enter key for add subscriber
document.getElementById('newEmail').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
addSubscriber();
}
});
// Auto-refresh stats every 30 seconds
setInterval(async () => {
try {
const response = await fetch('/api/stats');
const stats = await response.json();
document.querySelector('.stat-card.success .stat-number').textContent = stats.total_active;
document.querySelector('.stat-card.info .stat-number').textContent = stats.recent_signups;
document.querySelector('.stat-card.primary .stat-number').textContent = stats.newsletters_sent;
document.querySelector('.stat-card.warning .stat-number').textContent = stats.total_unsubscribed;
} catch (error) {
console.error('Error updating stats:', error);
}
}, 30000);
</script>
</body> </body>
</html> </html>

View file

@ -1,29 +1,383 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login</title> <title>Admin Login - Newsletter Admin</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #667eea;
--primary-dark: #5a67d8;
--secondary: #764ba2;
--success: #48bb78;
--error: #f56565;
--gray-100: #f7fafc;
--gray-200: #edf2f7;
--gray-300: #e2e8f0;
--gray-700: #4a5568;
--gray-800: #2d3748;
--white: #ffffff;
--shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
--border-radius: 8px;
--border-radius-lg: 12px;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.login-container {
background: var(--white);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-lg);
padding: 3rem;
width: 100%;
max-width: 400px;
text-align: center;
}
.login-header {
margin-bottom: 2rem;
}
.login-icon {
width: 4rem;
height: 4rem;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
color: white;
font-size: 1.5rem;
}
.login-title {
font-size: 1.75rem;
font-weight: 700;
color: var(--gray-800);
margin-bottom: 0.5rem;
}
.login-subtitle {
color: var(--gray-700);
font-size: 0.875rem;
}
.form-group {
margin-bottom: 1.5rem;
text-align: left;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--gray-700);
font-size: 0.875rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid var(--gray-300);
border-radius: var(--border-radius);
font-size: 1rem;
transition: all 0.3s ease;
background: var(--white);
}
.form-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.input-group {
position: relative;
}
.input-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--gray-700);
pointer-events: none;
}
.input-with-icon {
padding-left: 2.75rem;
}
.login-btn {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
border: none;
border-radius: var(--border-radius);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.login-btn:active {
transform: translateY(0);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.flash-messages {
margin-bottom: 1.5rem;
}
.flash {
padding: 1rem;
border-radius: var(--border-radius);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
text-align: left;
font-size: 0.875rem;
}
.flash.success {
background: #f0fff4;
color: #22543d;
border: 1px solid #c6f6d5;
}
.flash.error {
background: #fff5f5;
color: #742a2a;
border: 1px solid #fed7d7;
}
.flash.warning {
background: #fffbeb;
color: #744210;
border: 1px solid #feebc8;
}
.flash.info {
background: #ebf8ff;
color: #2a4a5a;
border: 1px solid #bee3f8;
}
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.forgot-password {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--gray-200);
}
.forgot-password a {
color: var(--primary);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.3s ease;
}
.forgot-password a:hover {
color: var(--primary-dark);
text-decoration: underline;
}
@media (max-width: 480px) {
.login-container {
padding: 2rem;
margin: 1rem;
}
.login-title {
font-size: 1.5rem;
}
}
.password-toggle {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--gray-700);
cursor: pointer;
padding: 0;
font-size: 0.875rem;
}
.password-toggle:hover {
color: var(--primary);
}
</style>
</head> </head>
<body> <body>
<h1>Admin Login</h1> <div class="login-container">
{% with messages = get_flashed_messages(with_categories=true) %} <div class="login-header">
{% if messages %} <div class="login-icon">
{% for category, message in messages %} <i class="fas fa-envelope"></i>
<div class="flash">{{ message }}</div> </div>
{% endfor %} <h1 class="login-title">Newsletter Admin</h1>
{% endif %} <p class="login-subtitle">Sign in to manage your newsletter</p>
{% endwith %} </div>
<form action="{{ url_for('login') }}" method="POST">
<label for="username">Username:</label>
<input type="text" name="username" required />
<label for="password">Password:</label>
<input type="password" name="password" required />
<button type="submit">Login</button>
</form>
</body>
</html> <!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash {{ category }}">
{% if category == 'success' %}
<i class="fas fa-check-circle"></i>
{% elif category == 'error' %}
<i class="fas fa-exclamation-circle"></i>
{% elif category == 'warning' %}
<i class="fas fa-exclamation-triangle"></i>
{% else %}
<i class="fas fa-info-circle"></i>
{% endif %}
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST" onsubmit="handleLogin(event)">
<div class="form-group">
<label for="username" class="form-label">Username</label>
<div class="input-group">
<i class="fas fa-user input-icon"></i>
<input type="text"
id="username"
name="username"
class="form-input input-with-icon"
placeholder="Enter your username"
required
autocomplete="username">
</div>
</div>
<div class="form-group">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<i class="fas fa-lock input-icon"></i>
<input type="password"
id="password"
name="password"
class="form-input input-with-icon"
placeholder="Enter your password"
required
autocomplete="current-password">
<button type="button" class="password-toggle" onclick="togglePassword()">
<i class="fas fa-eye" id="password-icon"></i>
</button>
</div>
</div>
<button type="submit" class="login-btn" id="login-btn">
<span id="login-text">Sign In</span>
<span id="login-spinner" class="spinner" style="display: none;"></span>
</button>
</form>
<div class="forgot-password">
<p>Having trouble signing in? Contact your administrator.</p>
</div>
</div>
<script>
function togglePassword() {
const passwordInput = document.getElementById('password');
const passwordIcon = document.getElementById('password-icon');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
passwordIcon.className = 'fas fa-eye-slash';
} else {
passwordInput.type = 'password';
passwordIcon.className = 'fas fa-eye';
}
}
function handleLogin(event) {
const loginBtn = document.getElementById('login-btn');
const loginText = document.getElementById('login-text');
const loginSpinner = document.getElementById('login-spinner');
// Show loading state
loginBtn.disabled = true;
loginText.style.display = 'none';
loginSpinner.style.display = 'inline-block';
// Reset form state after a short delay if needed
setTimeout(() => {
if (loginBtn.disabled) {
loginBtn.disabled = false;
loginText.style.display = 'inline';
loginSpinner.style.display = 'none';
}
}, 5000);
}
// Auto-focus username field
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('username').focus();
});
// Handle Enter key navigation
document.getElementById('username').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
document.getElementById('password').focus();
}
});
</script>
</body>
</html>

View file

@ -0,0 +1,667 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Send Newsletter - Newsletter Admin</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #667eea;
--primary-dark: #5a67d8;
--secondary: #764ba2;
--success: #48bb78;
--warning: #ed8936;
--error: #f56565;
--info: #4299e1;
--dark: #2d3748;
--light: #f7fafc;
--gray-100: #f7fafc;
--gray-200: #edf2f7;
--gray-300: #e2e8f0;
--gray-400: #cbd5e0;
--gray-500: #a0aec0;
--gray-600: #718096;
--gray-700: #4a5568;
--gray-800: #2d3748;
--gray-900: #1a202c;
--white: #ffffff;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--border-radius: 8px;
--border-radius-lg: 12px;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, var(--gray-100) 0%, var(--gray-200) 100%);
color: var(--gray-800);
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
padding: 2rem 0;
margin-bottom: 2rem;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-lg);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 2rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.75rem;
}
.nav-links {
display: flex;
gap: 1.5rem;
align-items: center;
}
.nav-links a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-links a:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.flash-messages {
margin-bottom: 1.5rem;
}
.flash {
padding: 1rem;
border-radius: var(--border-radius);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.flash.success {
background: #f0fff4;
color: #22543d;
border: 1px solid #c6f6d5;
}
.flash.error {
background: #fff5f5;
color: #742a2a;
border: 1px solid #fed7d7;
}
.flash.warning {
background: #fffbeb;
color: #744210;
border: 1px solid #feebc8;
}
.flash.info {
background: #ebf8ff;
color: #2a4a5a;
border: 1px solid #bee3f8;
}
.main-content {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 2rem;
}
.card {
background: var(--white);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.card-header {
padding: 1.5rem;
border-bottom: 1px solid var(--gray-200);
background: var(--gray-50);
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--gray-800);
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--gray-700);
font-size: 0.875rem;
}
.form-input {
width: 100%;
padding: 0.75rem;
border: 2px solid var(--gray-300);
border-radius: var(--border-radius);
font-size: 1rem;
transition: all 0.3s ease;
}
.form-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-textarea {
min-height: 300px;
resize: vertical;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--border-radius);
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
justify-content: center;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-secondary {
background: var(--gray-600);
color: white;
}
.btn-secondary:hover {
background: var(--gray-700);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-success {
background: var(--success);
color: white;
}
.btn-success:hover {
background: #38a169;
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-group {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.preview-container {
border: 2px dashed var(--gray-300);
border-radius: var(--border-radius);
padding: 2rem;
margin-top: 1rem;
background: var(--gray-50);
}
.preview-container.has-content {
border-color: var(--primary);
background: var(--white);
border-style: solid;
}
.email-preview {
background: var(--white);
border-radius: var(--border-radius);
padding: 1.5rem;
box-shadow: var(--shadow);
}
.email-preview h3 {
color: var(--gray-800);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--primary);
}
.email-body {
margin-bottom: 2rem;
line-height: 1.7;
}
.toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--gray-100);
border-radius: var(--border-radius);
border: 1px solid var(--gray-300);
}
.toolbar button {
padding: 0.5rem;
border: none;
background: var(--white);
border-radius: var(--border-radius);
cursor: pointer;
transition: all 0.3s ease;
color: var(--gray-600);
min-width: 2.5rem;
}
.toolbar button:hover {
background: var(--primary);
color: white;
}
.stats-info {
background: var(--gray-100);
border-radius: var(--border-radius);
padding: 1rem;
margin-bottom: 1rem;
}
.stats-info h3 {
color: var(--gray-800);
margin-bottom: 0.5rem;
font-size: 1rem;
}
.stats-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.25rem;
font-size: 0.875rem;
}
.stats-item strong {
color: var(--primary);
}
.tips {
background: var(--info);
color: white;
padding: 1rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
}
.tips h3 {
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tips ul {
margin-left: 1rem;
font-size: 0.875rem;
}
.tips li {
margin-bottom: 0.25rem;
}
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.header-content {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.nav-links {
flex-wrap: wrap;
justify-content: center;
}
.main-content {
grid-template-columns: 1fr;
}
.btn-group {
flex-direction: column;
}
}
</style>
</head>
<body>
<header class="header">
<div class="header-content">
<h1>
<i class="fas fa-paper-plane"></i>
Send Newsletter
</h1>
<nav class="nav-links">
<a href="/"><i class="fas fa-tachometer-alt"></i> Dashboard</a>
<a href="/subscribers"><i class="fas fa-users"></i> Subscribers</a>
<a href="/newsletter_history"><i class="fas fa-history"></i> History</a>
<a href="/logout"><i class="fas fa-sign-out-alt"></i> Logout</a>
</nav>
</div>
</header>
<div class="container">
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash {{ category }}">
{% if category == 'success' %}
<i class="fas fa-check-circle"></i>
{% elif category == 'error' %}
<i class="fas fa-exclamation-circle"></i>
{% elif category == 'warning' %}
<i class="fas fa-exclamation-triangle"></i>
{% else %}
<i class="fas fa-info-circle"></i>
{% endif %}
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="main-content">
<!-- Newsletter Form -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<i class="fas fa-edit"></i>
Compose Newsletter
</h2>
</div>
<div class="card-body">
<form method="POST" id="newsletter-form">
<div class="form-group">
<label for="subject" class="form-label">Subject Line</label>
<input type="text"
id="subject"
name="subject"
class="form-input"
placeholder="Enter a compelling subject line..."
value="{{ subject or '' }}"
required>
</div>
<div class="form-group">
<label for="body" class="form-label">Email Content (HTML allowed)</label>
<!-- Rich Text Toolbar -->
<div class="toolbar">
<button type="button" onclick="formatText('bold')" title="Bold">
<i class="fas fa-bold"></i>
</button>
<button type="button" onclick="formatText('italic')" title="Italic">
<i class="fas fa-italic"></i>
</button>
<button type="button" onclick="formatText('underline')" title="Underline">
<i class="fas fa-underline"></i>
</button>
<button type="button" onclick="insertLink()" title="Insert Link">
<i class="fas fa-link"></i>
</button>
<button type="button" onclick="insertList()" title="Insert List">
<i class="fas fa-list-ul"></i>
</button>
</div>
<textarea id="body"
name="body"
class="form-input form-textarea"
placeholder="Write your newsletter content here... HTML tags are supported."
required>{{ body or '' }}</textarea>
</div>
<div class="btn-group">
<button type="submit" name="action" value="preview" class="btn btn-secondary">
<i class="fas fa-eye"></i> Preview
</button>
<button type="submit" name="action" value="send" class="btn btn-success" id="send-btn">
<span id="send-text">
<i class="fas fa-paper-plane"></i> Send Newsletter
</span>
<span id="send-spinner" class="spinner" style="display: none;"></span>
</button>
</div>
</form>
</div>
</div>
<!-- Sidebar -->
<div>
<!-- Statistics -->
<div class="card">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-chart-bar"></i>
Quick Stats
</h3>
</div>
<div class="card-body">
<div class="stats-info" id="subscriber-stats">
<h3>Subscriber Information</h3>
<div class="stats-item">
<span>Active Subscribers:</span>
<strong id="active-count">Loading...</strong>
</div>
<div class="stats-item">
<span>New This Month:</span>
<strong id="recent-count">Loading...</strong>
</div>
<div class="stats-item">
<span>Total Sent:</span>
<strong id="sent-count">Loading...</strong>
</div>
</div>
</div>
</div>
<!-- Tips -->
<div class="tips">
<h3><i class="fas fa-lightbulb"></i> Writing Tips</h3>
<ul>
<li>Keep subject lines under 50 characters</li>
<li>Use personalization when possible</li>
<li>Include a clear call-to-action</li>
<li>Test your content before sending</li>
<li>Mobile-friendly formatting is key</li>
</ul>
</div>
<!-- Preview Container -->
{% if preview %}
<div class="card">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-eye"></i>
Email Preview
</h3>
</div>
<div class="card-body">
<div class="preview-container has-content">
{{ preview|safe }}
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<script>
// Load subscriber stats
async function loadStats() {
try {
const response = await fetch('/api/stats');
const stats = await response.json();
document.getElementById('active-count').textContent = stats.total_active;
document.getElementById('recent-count').textContent = stats.recent_signups;
document.getElementById('sent-count').textContent = stats.newsletters_sent;
} catch (error) {
console.error('Error loading stats:', error);
document.getElementById('active-count').textContent = 'Error';
document.getElementById('recent-count').textContent = 'Error';
document.getElementById('sent-count').textContent = 'Error';
}
}
// Rich text formatting functions
function formatText(command) {
const textarea = document.getElementById('body');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
if (selectedText) {
let formattedText = '';
switch (command) {
case 'bold':
formattedText = `<strong>${selectedText}</strong>`;
break;
case 'italic':
formattedText = `<em>${selectedText}</em>`;
break;
case 'underline':
formattedText = `<u>${selectedText}</u>`;
break;
}
textarea.value = textarea.value.substring(0, start) + formattedText + textarea.value.substring(end);
textarea.focus();
textarea.setSelectionRange(start, start + formattedText.length);
}
}
function insertLink() {
const url = prompt('Enter the URL:');
const text = prompt('Enter the link text:') || url;
if (url) {
const textarea = document.getElementById('body');
const start = textarea.selectionStart;
const linkText = `<a href="${url}">${text}</a>`;
textarea.value = textarea.value.substring(0, start) + linkText + textarea.value.substring(textarea.selectionEnd);
textarea.focus();
textarea.setSelectionRange(start + linkText.length, start + linkText.length);
}
}
function insertList() {
const textarea = document.getElementById('body');
const start = textarea.selectionStart;
const listText = `<ul>\n <li>List item 1</li>\n <li>List item 2</li>\n <li>List item 3</li>\n</ul>`;
textarea.value = textarea.value.substring(0, start) + listText + textarea.value.substring(textarea.selectionEnd);
textarea.focus();
textarea.setSelectionRange(start + listText.length, start + listText.length);
}
// Handle form submission
document.getElementById('newsletter-form').addEventListener('submit', function(e) {
const action = e.submitter.value;
if (action === 'send') {
if (!confirm('Are you sure you want to send this newsletter to all subscribers? This action cannot be undone.')) {
e.preventDefault();
return;
}
// Show loading state
const sendBtn = document.getElementById('send-btn');
const sendText = document.getElementById('send-text');
const sendSpinner = document.getElementById('send-spinner');
sendBtn.disabled = true;
sendText.style.display = 'none';
sendSpinner

View file

@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Center - Send Update</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<h1>Send Update Email</h1>
<p>
<a href="{{ url_for('index') }}">Back to Subscribers List</a> |
<a href="{{ url_for('logout') }}">Logout</a>
</p>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="flash">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form action="{{ url_for('send_update') }}" method="POST">
<label for="subject">Subject:</label>
<input type="text" name="subject" required>
<label for="body">Body (HTML allowed):</label>
<textarea name="body" rows="10" required></textarea>
<button type="submit">Send Update</button>
</form>
</body>
</html>