Compare commits

..

10 commits

10 changed files with 852 additions and 2785 deletions

View file

@ -1,18 +1,30 @@
FROM python:3.11-slim-buster
# Use an official Python runtime as a base
FROM python:3.11-slim-bookworm
# Install build dependencies (build-essential provides gcc and other tools)
RUN apt-get update && apt-get install -y build-essential
# Set working directory
WORKDIR /app
WORKDIR /rideaware_landing
# Install system dependencies
RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first to leverage Docker cache
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
ENV FLASK_APP=server.py
# Environment variables
ENV FLASK_APP=app.py
ENV FLASK_ENV=production
ENV ENVIRONMENT=production
EXPOSE 5001
CMD ["gunicorn", "--bind", "0.0.0.0:5001", "app:app"]
# Use Gunicorn as production server
CMD ["gunicorn", "--bind", "0.0.0.0:5001", "--workers", "4", "--timeout", "120", "app:app"]

512
app.py
View file

@ -1,10 +1,9 @@
import os
import logging
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import concurrent.futures
from threading import Lock
from functools import wraps
from urllib.parse import urlparse, urljoin
from flask import (
Flask,
render_template,
@ -13,402 +12,233 @@ from flask import (
url_for,
flash,
session,
jsonify,
abort
)
from markupsafe import escape
from dotenv import load_dotenv
from werkzeug.security import check_password_hash
from functools import wraps
import re
from database import (
get_db_connection, init_db, get_all_emails, get_admin, create_default_admin,
get_subscriber_stats, add_email, remove_email, save_newsletter,
update_newsletter_stats, log_email_delivery, get_recent_newsletters
get_connection,
init_db,
get_all_emails,
get_admin,
create_default_admin,
)
load_dotenv()
app = Flask(__name__)
app.secret_key = os.getenv("SECRET_KEY")
base_url = os.getenv("BASE_URL")
base_url = os.getenv("BASE_URL", "").strip().strip("/")
# SMTP settings
SMTP_SERVER = os.getenv("SMTP_SERVER")
SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER)
SENDER_NAME = os.getenv("SENDER_NAME", "Newsletter Admin")
# Email sending configuration
MAX_EMAIL_WORKERS = 5
email_send_lock = Lock()
# Logging setup
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# Initialize the database and create default admin user
init_db()
create_default_admin()
# Security decorators
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if "username" not in session:
if request.is_json:
return jsonify({"error": "Authentication required"}), 401
flash("Please log in to access this page.", "warning")
return redirect(url_for("login"))
next_url = request.full_path if request.query_string else request.path
return redirect(url_for("login", next=next_url))
return f(*args, **kwargs)
return decorated_function
def validate_email(email):
"""Validate email format."""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def send_single_email(email_data):
"""Send a single email (for thread pool execution)."""
email, subject, body, newsletter_id = email_data
def get_dashboard_counts():
"""Return dict of counts: total subscribers, total newsletters, sent today."""
counts = {"total_subscribers": 0, "total_newsletters": 0, "sent_today": 0}
try:
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=15)
server.login(SMTP_USER, SMTP_PASSWORD)
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM subscribers")
counts["total_subscribers"] = cur.fetchone()[0] or 0
# Create message
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f"{SENDER_NAME} <{SENDER_EMAIL}>"
msg['To'] = email
cur.execute("SELECT COUNT(*) FROM newsletters")
counts["total_newsletters"] = cur.fetchone()[0] or 0
# Create unsubscribe link
cur.execute(
"""
SELECT COUNT(*)
FROM newsletters
WHERE sent_at::date = CURRENT_DATE
"""
)
counts["sent_today"] = cur.fetchone()[0] or 0
cur.close()
conn.close()
except Exception:
pass
return counts
def is_safe_url(target: str) -> bool:
if not target:
return False
ref = urlparse(request.host_url)
test = urlparse(urljoin(request.host_url, target))
return test.scheme in ("http", "https") and ref.netloc == test.netloc
def send_update_email(subject: str, body_html: str, email: str) -> bool:
"""Send a single HTML email with retries."""
max_retries = 3
retry_count = 0
unsub_link = ""
if base_url:
unsub_link = f"https://{base_url}/unsubscribe?email={email}"
# HTML body with unsubscribe link
html_body = f"""
<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>
"""
# Attach HTML part
html_part = MIMEText(html_body, 'html')
msg.attach(html_part)
if unsub_link:
custom_body = (
f"{body_html}"
f"<br><br>"
f"If you ever wish to unsubscribe, please click "
f"<a href='{unsub_link}'>here</a>."
)
else:
custom_body = body_html
server.sendmail(SENDER_EMAIL, email, msg.as_string())
server.quit()
# Log successful delivery
if newsletter_id:
log_email_delivery(newsletter_id, email, 'sent')
logger.info(f"Email sent successfully to: {email}")
return True, email, None
while retry_count < max_retries:
try:
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
server.set_debuglevel(0)
server.login(SMTP_USER, SMTP_PASSWORD)
msg = MIMEText(custom_body, "html", "utf-8")
msg["Subject"] = subject
msg["From"] = SENDER_EMAIL
msg["To"] = email
server.sendmail(SENDER_EMAIL, [email], msg.as_string())
server.quit()
return True
except Exception:
retry_count += 1
if retry_count >= max_retries:
break
import time
time.sleep(1.0)
return False
def process_send_update_email(subject: str, body_html: str) -> str:
"""Send update email to all subscribers and log newsletter content."""
try:
subscribers = get_all_emails()
if not subscribers:
return "No subscribers found."
failures = []
for email in subscribers:
if not send_update_email(subject, body_html, email):
failures.append(email)
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"INSERT INTO newsletters (subject, body) VALUES (%s, %s)",
(subject, body_html),
)
conn.commit()
cursor.close()
conn.close()
except Exception:
pass
if failures:
return f"Sent with failures: {len(failures)} recipients failed."
return "Email has been sent to all subscribers."
except Exception as e:
error_msg = str(e)
logger.error(f"Failed to send email to {email}: {error_msg}")
# Log failed delivery
if newsletter_id:
log_email_delivery(newsletter_id, email, 'failed', error_msg)
return False, email, error_msg
return f"Failed to send email: {e}"
def send_newsletter_batch(subject, body, email_list, newsletter_id=None):
"""Send newsletter to multiple recipients using thread pool."""
success_count = 0
failure_count = 0
failed_emails = []
# Prepare email data for thread pool
email_data_list = [(email, subject, body, newsletter_id) for email in email_list]
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_EMAIL_WORKERS) as executor:
future_to_email = {
executor.submit(send_single_email, email_data): email_data[0]
for email_data in email_data_list
}
for future in concurrent.futures.as_completed(future_to_email):
success, email, error = future.result()
if success:
success_count += 1
else:
failure_count += 1
failed_emails.append({'email': email, 'error': error})
return success_count, failure_count, failed_emails
@app.route("/")
@app.route("/", methods=["GET"])
@login_required
def index():
"""Dashboard with subscriber statistics and recent activity."""
page = request.args.get('page', 1, type=int)
search = request.args.get('search', '')
per_page = 25
stats = get_subscriber_stats()
subscribers_data = get_all_emails(page=page, per_page=per_page, search=search)
recent_newsletters = get_recent_newsletters(limit=5)
return render_template(
"admin_index.html",
stats=stats,
subscribers=subscribers_data['subscribers'],
pagination={
'page': subscribers_data['page'],
'per_page': subscribers_data['per_page'],
'total_pages': subscribers_data['total_pages'],
'total_count': subscribers_data['total_count']
},
search=search,
recent_newsletters=recent_newsletters
)
@app.route("/subscribers")
@login_required
def subscribers():
"""Detailed subscriber management page."""
page = request.args.get('page', 1, type=int)
search = request.args.get('search', '')
per_page = 50
subscribers_data = get_all_emails(page=page, per_page=per_page, search=search)
return render_template(
"subscribers.html",
subscribers=subscribers_data['subscribers'],
pagination={
'page': subscribers_data['page'],
'per_page': subscribers_data['per_page'],
'total_pages': subscribers_data['total_pages'],
'total_count': subscribers_data['total_count']
},
search=search
)
@app.route("/add_subscriber", methods=["POST"])
@login_required
def add_subscriber():
"""Add a new subscriber via AJAX."""
"""Dashboard: list subscriber emails and show widgets."""
emails = []
try:
data = request.get_json()
email = data.get('email', '').strip().lower()
if not email or not validate_email(email):
return jsonify({"success": False, "message": "Invalid email format"}), 400
success = add_email(email, source='admin_manual')
if success:
return jsonify({"success": True, "message": f"Successfully added {email}"})
else:
return jsonify({"success": False, "message": "Email already exists or failed to add"}), 400
except Exception as e:
logger.error(f"Error adding subscriber: {e}")
return jsonify({"success": False, "message": "Server error occurred"}), 500
emails = get_all_emails()
except Exception:
flash("Could not load subscribers right now.", "danger")
@app.route("/remove_subscriber", methods=["POST"])
@login_required
def remove_subscriber():
"""Remove/unsubscribe a subscriber via AJAX."""
try:
data = request.get_json()
email = data.get('email', '').strip().lower()
if not email:
return jsonify({"success": False, "message": "Email is required"}), 400
success = remove_email(email)
if success:
return jsonify({"success": True, "message": f"Successfully unsubscribed {email}"})
else:
return jsonify({"success": False, "message": "Email not found"}), 404
except Exception as e:
logger.error(f"Error removing subscriber: {e}")
return jsonify({"success": False, "message": "Server error occurred"}), 500
counts = get_dashboard_counts()
return render_template("admin_index.html", emails=emails, counts=counts)
@app.route("/send_newsletter", methods=["GET", "POST"])
@app.route("/send_update", methods=["GET", "POST"])
@login_required
def send_newsletter():
"""Enhanced newsletter sending with preview and batch processing."""
def send_update():
if request.method == "POST":
action = request.form.get('action', 'send')
subject = request.form.get('subject', '').strip()
body = request.form.get('body', '').strip()
if not subject or not body:
flash("Subject and body are required", "error")
return redirect(url_for('send_newsletter'))
if action == 'preview':
# Return preview
preview_html = f"""
<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")
subject = (request.form.get("subject") or "").strip()
body_html = request.form.get("body") or ""
if not subject or not body_html:
flash("Subject and body are required", "danger")
return redirect(url_for("send_update"))
result_message = process_send_update_email(subject, body_html)
flash(escape(result_message))
return redirect(url_for("send_update"))
return render_template("send_update.html")
@app.route("/newsletter_history")
@login_required
def newsletter_history():
"""View newsletter sending history."""
newsletters = get_recent_newsletters(limit=50)
return render_template("newsletter_history.html", newsletters=newsletters)
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
username = (request.form.get("username") or "").strip()
password = (request.form.get("password") or "").strip()
if not username or not password:
flash("Username and password are required", "error")
flash("Username and password are required", "danger")
return redirect(url_for("login"))
admin = get_admin(username)
if admin and len(admin) >= 3 and check_password_hash(admin[2], password):
if len(admin) >= 4 and not admin[3]: # Check is_active
flash("Account is disabled", "error")
return redirect(url_for("login"))
if admin and check_password_hash(admin[1], password):
session["username"] = username
session["admin_id"] = admin[0]
flash("Logged in successfully!", "success")
# Redirect to intended page or dashboard
next_page = request.args.get('next')
if next_page:
return redirect(next_page)
session.permanent = True
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SECURE"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
next_url = request.args.get("next")
if next_url and is_safe_url(next_url):
flash("Logged in successfully", "success")
return redirect(next_url)
flash("Logged in successfully", "success")
return redirect(url_for("index"))
else:
flash("Invalid username or password", "error")
flash("Invalid username or password", "danger")
return redirect(url_for("login"))
return render_template("login.html")
@app.route("/logout")
@app.route("/logout", methods=["GET"])
def logout():
session.clear()
flash("You have been logged out successfully", "info")
session.pop("username", None)
flash("Logged out successfully", "success")
return redirect(url_for("login"))
# Public unsubscribe endpoint
@app.route("/unsubscribe")
def unsubscribe():
"""Public unsubscribe endpoint."""
email = request.args.get('email', '').strip().lower()
if not email or not validate_email(email):
return render_template("unsubscribe.html", error="Invalid email address")
success = remove_email(email)
if success:
return render_template("unsubscribe.html", success=True, email=email)
else:
return render_template("unsubscribe.html", error="Email not found or already unsubscribed")
# API endpoints for AJAX requests
@app.route("/api/stats")
@login_required
def api_stats():
"""API endpoint for dashboard statistics."""
stats = get_subscriber_stats()
return jsonify(stats)
# Error handlers
@app.errorhandler(404)
def not_found(error):
return render_template("error.html", error="Page not found", code=404), 404
@app.errorhandler(500)
def internal_error(error):
logger.error(f"Internal error: {error}")
return render_template("error.html", error="Internal server error", code=500), 500
# Context processors
@app.context_processor
def inject_user():
return dict(current_user=session.get('username'))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5001, debug=True)
is_prod = os.getenv("ENVIRONMENT", "development").lower() == "production"
app.config["PREFERRED_URL_SCHEME"] = "https" if is_prod else "http"
if is_prod:
app.run(host="0.0.0.0", port=5001, debug=False, use_reloader=False)
else:
app.run(host="0.0.0.0", port=5001, debug=True, use_reloader=True)

View file

@ -1,522 +1,234 @@
import os
import logging
import psycopg2
from psycopg2 import IntegrityError, pool
from psycopg2 import IntegrityError, pool, OperationalError
from dotenv import load_dotenv
from werkzeug.security import generate_password_hash
from contextlib import contextmanager
from datetime import datetime, timezone
load_dotenv()
# Logging setup
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
try:
DB_MIN_CONN = int(os.getenv("DB_MIN_CONN", 1))
DB_MAX_CONN = int(os.getenv("DB_MAX_CONN", 10))
# Connection pool for better performance
connection_pool = None
def init_connection_pool():
"""Initialize the connection pool."""
global connection_pool
try:
connection_pool = psycopg2.pool.ThreadedConnectionPool(
1, 20, # min and max connections
host=os.getenv("PG_HOST"),
port=os.getenv("PG_PORT"),
dbname=os.getenv("PG_DATABASE"),
user=os.getenv("PG_USER"),
password=os.getenv("PG_PASSWORD"),
)
logger.info("Connection pool created successfully")
except Exception as e:
logger.error(f"Connection pool creation error: {e}")
raise
@contextmanager
def get_db_connection():
"""Context manager for database connections."""
if connection_pool is None:
init_connection_pool()
conn = None
try:
conn = connection_pool.getconn()
yield conn
except Exception as e:
if conn:
conn.rollback()
logger.error(f"Database operation error: {e}")
raise
finally:
if conn:
connection_pool.putconn(conn)
def check_column_exists(cursor, table_name, column_name):
"""Check if a column exists in a table."""
cursor.execute(
"""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_name = %s AND column_name = %s
)
""",
(table_name, column_name)
conn_pool = pool.ThreadedConnectionPool(
minconn=DB_MIN_CONN,
maxconn=DB_MAX_CONN,
host=os.getenv("PG_HOST"),
port=os.getenv("PG_PORT"),
dbname=os.getenv("PG_DATABASE"),
user=os.getenv("PG_USER"),
password=os.getenv("PG_PASSWORD"),
connect_timeout=10,
)
return cursor.fetchone()[0]
except OperationalError:
raise
except Exception:
raise
def get_connection():
"""Get a connection from the connection pool."""
return conn_pool.getconn()
def migrate_database(conn):
"""Apply database migrations to upgrade existing schema."""
cursor = conn.cursor()
# Migration 1: Add new columns to subscribers table
if not check_column_exists(cursor, 'subscribers', 'subscribed_at'):
logger.info("Adding subscribed_at column to subscribers table")
cursor.execute(
"ALTER TABLE subscribers ADD COLUMN subscribed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP"
)
if not check_column_exists(cursor, 'subscribers', 'status'):
logger.info("Adding status column to subscribers table")
cursor.execute(
"ALTER TABLE subscribers ADD COLUMN status TEXT DEFAULT 'active'"
)
# Add check constraint
cursor.execute(
"ALTER TABLE subscribers ADD CONSTRAINT subscribers_status_check CHECK (status IN ('active', 'unsubscribed'))"
)
# Update existing rows
cursor.execute("UPDATE subscribers SET status = 'active' WHERE status IS NULL")
if not check_column_exists(cursor, 'subscribers', 'source'):
logger.info("Adding source column to subscribers table")
cursor.execute(
"ALTER TABLE subscribers ADD COLUMN source TEXT DEFAULT 'manual'"
)
# Migration 2: Add new columns to admin_users table
if not check_column_exists(cursor, 'admin_users', 'created_at'):
logger.info("Adding created_at column to admin_users table")
cursor.execute(
"ALTER TABLE admin_users ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP"
)
if not check_column_exists(cursor, 'admin_users', 'last_login'):
logger.info("Adding last_login column to admin_users table")
cursor.execute(
"ALTER TABLE admin_users ADD COLUMN last_login TIMESTAMP WITH TIME ZONE"
)
if not check_column_exists(cursor, 'admin_users', 'is_active'):
logger.info("Adding is_active column to admin_users table")
cursor.execute(
"ALTER TABLE admin_users ADD COLUMN is_active BOOLEAN DEFAULT TRUE"
)
# Migration 3: Add new columns to newsletters table
if not check_column_exists(cursor, 'newsletters', 'sent_by'):
logger.info("Adding sent_by column to newsletters table")
cursor.execute(
"ALTER TABLE newsletters ADD COLUMN sent_by TEXT"
)
if not check_column_exists(cursor, 'newsletters', 'recipient_count'):
logger.info("Adding recipient_count column to newsletters table")
cursor.execute(
"ALTER TABLE newsletters ADD COLUMN recipient_count INTEGER DEFAULT 0"
)
if not check_column_exists(cursor, 'newsletters', 'success_count'):
logger.info("Adding success_count column to newsletters table")
cursor.execute(
"ALTER TABLE newsletters ADD COLUMN success_count INTEGER DEFAULT 0"
)
if not check_column_exists(cursor, 'newsletters', 'failure_count'):
logger.info("Adding failure_count column to newsletters table")
cursor.execute(
"ALTER TABLE newsletters ADD COLUMN failure_count INTEGER DEFAULT 0"
)
conn.commit()
logger.info("Database migrations completed successfully")
def init_db():
"""Initialize the database tables with improved schema."""
"""Initialize database tables with connection pool."""
conn = None
cursor = None
try:
with get_db_connection() as conn:
cursor = conn.cursor()
conn = get_connection()
cursor = conn.cursor()
# Create basic subscribers table (backwards compatible)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS subscribers (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS subscribers (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL
)
# Create basic admin_users table (backwards compatible)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS admin_users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)
"""
)
)
# Create basic newsletters table (backwards compatible)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS newsletters (
id SERIAL PRIMARY KEY,
subject TEXT NOT NULL,
body TEXT NOT NULL,
sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS admin_users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)
# Email delivery tracking (new table)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS email_deliveries (
id SERIAL PRIMARY KEY,
newsletter_id INTEGER REFERENCES newsletters(id),
email TEXT NOT NULL,
status TEXT CHECK (status IN ('sent', 'failed', 'bounced')),
sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
error_message TEXT
)
"""
)
)
conn.commit()
logger.info("Basic database tables created successfully")
# Apply migrations to upgrade schema
migrate_database(conn)
# Add indexes after migrations are complete
try:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscribers_status ON subscribers(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_newsletters_sent_at ON newsletters(sent_at)")
conn.commit()
logger.info("Database indexes created successfully")
except Exception as e:
logger.warning(f"Some indexes may not have been created: {e}")
# Continue anyway as this is not critical
logger.info("Database initialization completed successfully")
except Exception as e:
logger.error(f"Database initialization error: {e}")
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS newsletters (
id SERIAL PRIMARY KEY,
subject TEXT NOT NULL,
body TEXT NOT NULL,
sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.commit()
except Exception:
if conn:
conn.rollback()
raise
finally:
if cursor:
cursor.close()
if conn:
conn_pool.putconn(conn)
def get_subscriber_stats():
"""Get comprehensive subscriber statistics."""
def get_all_emails():
"""Return a list of all subscriber emails."""
conn = None
cursor = None
try:
with get_db_connection() as conn:
cursor = conn.cursor()
# Check if status column exists
has_status = check_column_exists(cursor, 'subscribers', 'status')
if has_status:
# Get total subscribers with status filtering
cursor.execute("SELECT COUNT(*) FROM subscribers WHERE status = 'active'")
total_active = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM subscribers WHERE status = 'unsubscribed'")
total_unsubscribed = cursor.fetchone()[0]
# Get recent signups (last 30 days) - check if subscribed_at exists
has_subscribed_at = check_column_exists(cursor, 'subscribers', 'subscribed_at')
if has_subscribed_at:
cursor.execute("""
SELECT COUNT(*) FROM subscribers
WHERE subscribed_at >= NOW() - INTERVAL '30 days' AND status = 'active'
""")
recent_signups = cursor.fetchone()[0]
else:
recent_signups = 0
else:
# Fallback for old schema
cursor.execute("SELECT COUNT(*) FROM subscribers")
total_active = cursor.fetchone()[0]
total_unsubscribed = 0
recent_signups = 0
# Get newsletters sent
cursor.execute("SELECT COUNT(*) FROM newsletters")
newsletters_sent = cursor.fetchone()[0]
return {
'total_active': total_active,
'total_unsubscribed': total_unsubscribed,
'recent_signups': recent_signups,
'newsletters_sent': newsletters_sent
}
except Exception as e:
logger.error(f"Error retrieving subscriber stats: {e}")
return {'total_active': 0, 'total_unsubscribed': 0, 'recent_signups': 0, 'newsletters_sent': 0}
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT email FROM subscribers")
results = cursor.fetchall()
return [row[0] for row in results]
except Exception:
return []
finally:
if cursor:
cursor.close()
if conn:
conn_pool.putconn(conn)
def get_all_emails(page=1, per_page=50, search=''):
"""Return paginated list of subscriber emails with search functionality."""
try:
with get_db_connection() as conn:
cursor = conn.cursor()
# Check which columns exist
has_status = check_column_exists(cursor, 'subscribers', 'status')
has_subscribed_at = check_column_exists(cursor, 'subscribers', 'subscribed_at')
has_source = check_column_exists(cursor, 'subscribers', 'source')
# Calculate offset
offset = (page - 1) * per_page
# Build base query based on available columns
if has_status:
base_query = "FROM subscribers WHERE status = 'active'"
else:
base_query = "FROM subscribers WHERE 1=1"
params = []
if search:
base_query += " AND email ILIKE %s"
params.append(f"%{search}%")
# Get total count
cursor.execute(f"SELECT COUNT(*) {base_query}", params)
total_count = cursor.fetchone()[0]
# Build select query based on available columns
select_fields = ["id", "email"]
if has_subscribed_at:
select_fields.append("subscribed_at")
if has_source:
select_fields.append("source")
# Get paginated results
query = f"""
SELECT {', '.join(select_fields)}
{base_query}
ORDER BY {"subscribed_at DESC" if has_subscribed_at else "id DESC"}
LIMIT %s OFFSET %s
"""
params.extend([per_page, offset])
cursor.execute(query, params)
results = cursor.fetchall()
subscribers = []
for row in results:
subscriber = {
'id': row[0],
'email': row[1],
'subscribed_at': row[2] if has_subscribed_at and len(row) > 2 else None,
'source': row[3] if has_source and len(row) > 3 else 'manual'
}
subscribers.append(subscriber)
return {
'subscribers': subscribers,
'total_count': total_count,
'page': page,
'per_page': per_page,
'total_pages': (total_count + per_page - 1) // per_page
}
except Exception as e:
logger.error(f"Error retrieving emails: {e}")
return {'subscribers': [], 'total_count': 0, 'page': 1, 'per_page': per_page, 'total_pages': 0}
def add_email(email, source='manual'):
def add_email(email):
"""Insert an email into the subscribers table."""
conn = None
cursor = None
try:
with get_db_connection() as conn:
cursor = conn.cursor()
# Check if source column exists
has_source = check_column_exists(cursor, 'subscribers', 'source')
if has_source:
cursor.execute(
"INSERT INTO subscribers (email, source) VALUES (%s, %s)",
(email.lower().strip(), source)
)
else:
cursor.execute(
"INSERT INTO subscribers (email) VALUES (%s)",
(email.lower().strip(),)
)
conn.commit()
logger.info(f"Email {email} added successfully.")
return True
conn = get_connection()
cursor = conn.cursor()
cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,))
conn.commit()
return True
except IntegrityError:
logger.warning(f"Attempted to add duplicate email: {email}")
if conn:
conn.rollback()
return False
except Exception as e:
logger.error(f"Error adding email {email}: {e}")
except Exception:
if conn:
conn.rollback()
return False
finally:
if cursor:
cursor.close()
if conn:
conn_pool.putconn(conn)
def remove_email(email):
"""Mark email as unsubscribed or delete if status column doesn't exist."""
"""Remove an email from the subscribers table."""
conn = None
cursor = None
try:
with get_db_connection() as conn:
cursor = conn.cursor()
# Check if status column exists
has_status = check_column_exists(cursor, 'subscribers', 'status')
if has_status:
# Mark as unsubscribed
cursor.execute(
"UPDATE subscribers SET status = 'unsubscribed' WHERE email = %s",
(email,)
)
else:
# Delete the record (old behavior)
cursor.execute(
"DELETE FROM subscribers WHERE email = %s",
(email,)
)
rowcount = cursor.rowcount
conn.commit()
logger.info(f"Email {email} {'unsubscribed' if has_status else 'removed'} successfully.")
return rowcount > 0
except Exception as e:
logger.error(f"Error {'unsubscribing' if has_status else 'removing'} email {email}: {e}")
conn = get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,))
rowcount = cursor.rowcount
conn.commit()
return rowcount > 0
except Exception:
if conn:
conn.rollback()
return False
finally:
if cursor:
cursor.close()
if conn:
conn_pool.putconn(conn)
def get_admin(username):
"""Retrieve admin credentials and update last login."""
"""Retrieve admin credentials for a given username.
Returns a tuple (username, password_hash) if found, otherwise None.
"""
conn = None
cursor = None
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""SELECT id, username, password, is_active
FROM admin_users
WHERE username = %s AND is_active = TRUE""",
(username,),
)
result = cursor.fetchone()
if result:
# Update last login
cursor.execute(
"UPDATE admin_users SET last_login = %s WHERE id = %s",
(datetime.now(timezone.utc), result[0])
)
conn.commit()
return result
except Exception as e:
logger.error(f"Error retrieving admin: {e}")
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT username, password FROM admin_users WHERE username = %s",
(username,),
)
return cursor.fetchone()
except Exception:
return None
finally:
if cursor:
cursor.close()
if conn:
conn_pool.putconn(conn)
def create_default_admin():
"""Create a default admin user if one doesn't already exist."""
default_username = os.getenv("ADMIN_USERNAME", "admin")
default_password = os.getenv("ADMIN_PASSWORD", "changeme")
hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256")
try:
with get_db_connection() as conn:
cursor = conn.cursor()
# Check if any admin exists
cursor.execute("SELECT COUNT(*) FROM admin_users WHERE is_active = TRUE")
admin_count = cursor.fetchone()[0]
if admin_count == 0:
cursor.execute(
"INSERT INTO admin_users (username, password) VALUES (%s, %s)",
(default_username, hashed_password),
)
conn.commit()
logger.info("Default admin created successfully")
conn = None
cursor = None
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT id FROM admin_users WHERE username = %s", (default_username,)
)
exists = cursor.fetchone()
if exists is None:
cursor.execute(
"INSERT INTO admin_users (username, password) VALUES (%s, %s)",
(default_username, hashed_password),
)
conn.commit()
except Exception:
if conn:
conn.rollback()
finally:
if cursor:
cursor.close()
if conn:
conn_pool.putconn(conn)
def close_pool():
"""Close the database connection pool."""
try:
conn_pool.closeall()
except Exception:
pass
class DatabaseContext:
"""Optional context manager for manual transactions."""
def __init__(self):
self.conn = None
self.cursor = None
def __enter__(self):
self.conn = get_connection()
self.cursor = self.conn.cursor()
return self.cursor
def __exit__(self, exc_type, exc_val, exc_tb):
try:
if exc_type:
self.conn.rollback()
else:
logger.info("Admin users already exist")
except Exception as e:
logger.error(f"Error creating default admin: {e}")
def save_newsletter(subject, body, sent_by, recipient_count=0):
"""Save newsletter to database and return the ID."""
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""INSERT INTO newsletters (subject, body, sent_by, recipient_count)
VALUES (%s, %s, %s, %s) RETURNING id""",
(subject, body, sent_by, recipient_count)
)
newsletter_id = cursor.fetchone()[0]
conn.commit()
return newsletter_id
except Exception as e:
logger.error(f"Error saving newsletter: {e}")
return None
def update_newsletter_stats(newsletter_id, success_count, failure_count):
"""Update newsletter delivery statistics."""
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""UPDATE newsletters
SET success_count = %s, failure_count = %s
WHERE id = %s""",
(success_count, failure_count, newsletter_id)
)
conn.commit()
except Exception as e:
logger.error(f"Error updating newsletter stats: {e}")
def log_email_delivery(newsletter_id, email, status, error_message=None):
"""Log individual email delivery attempt."""
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""INSERT INTO email_deliveries (newsletter_id, email, status, error_message)
VALUES (%s, %s, %s, %s)""",
(newsletter_id, email, status, error_message)
)
conn.commit()
except Exception as e:
logger.error(f"Error logging email delivery: {e}")
def get_recent_newsletters(limit=10):
"""Get recent newsletters with statistics."""
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""SELECT id, subject, sent_at, sent_by, recipient_count, success_count, failure_count
FROM newsletters
ORDER BY sent_at DESC
LIMIT %s""",
(limit,)
)
results = cursor.fetchall()
return [{
'id': row[0],
'subject': row[1],
'sent_at': row[2],
'sent_by': row[3],
'recipient_count': row[4],
'success_count': row[5],
'failure_count': row[6]
} for row in results]
except Exception as e:
logger.error(f"Error retrieving recent newsletters: {e}")
return []
self.conn.commit()
finally:
if self.cursor:
self.cursor.close()
if self.conn:
conn_pool.putconn(self.conn)

View file

@ -3,3 +3,6 @@ flask
python-dotenv
Werkzeug
psycopg2-binary
psycopg2-pool
python-decouple
markupsafe

View file

@ -1,55 +1,306 @@
:root {
--primary: #2563eb;
--primary-hover: #1d4ed8;
--bg: #f5f7fb;
--bg-grad-1: #f8fbff;
--bg-grad-2: #eef3fb;
--surface: #ffffff;
--text: #0f172a;
--muted: #64748b;
--border: #e5e7eb;
--ring: rgba(37, 99, 235, 0.25);
--radius: 14px;
--shadow-1: 0 8px 24px rgba(15, 23, 42, 0.08);
--shadow-2: 0 14px 38px rgba(15, 23, 42, 0.12);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
}
body {
font-family: Arial, sans-serif;
padding: 20px;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
color: var(--text);
background: radial-gradient(1000px 650px at 0% 0%, var(--bg-grad-1), transparent 60%),
radial-gradient(900px 600px at 100% 0%, var(--bg-grad-2), transparent 55%),
var(--bg);
min-height: 100vh;
}
.navbar {
position: sticky;
top: 0;
z-index: 1000;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
}
.navbar-content {
max-width: 1100px;
margin: 0 auto;
padding: 14px 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
color: var(--text);
font-weight: 700;
text-decoration: none;
letter-spacing: 0.2px;
}
.navbar-links a {
color: var(--muted);
text-decoration: none;
margin-left: 14px;
padding: 8px 12px;
border-radius: 10px;
transition: all 0.2s ease;
}
.navbar-links a:hover {
color: var(--text);
background: rgba(15, 23, 42, 0.05);
}
.navbar-links .logout {
color: #b91c1c;
}
.navbar-links .logout:hover {
background: rgba(185, 28, 28, 0.08);
}
.container {
max-width: 1100px;
margin: 28px auto 48px;
padding: 0 20px;
padding-bottom: 64px;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
border-top: 1px solid rgba(15, 23, 42, 0.06);
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
}
.footer-inner {
max-width: 1100px;
margin: 0 auto;
padding: 14px 20px;
color: var(--muted);
font-size: 14px;
}
.page-header {
margin-bottom: 18px;
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.page-title {
margin: 0 0 4px 0;
font-size: 26px;
font-weight: 700;
}
.page-subtitle {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.page-actions {
display: flex;
gap: 10px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-1);
padding: 20px;
}
.empty-state {
text-align: center;
color: var(--muted);
}
.widgets {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 18px;
}
.widget-card {
background: linear-gradient(180deg, #fff, #fafcff);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-1);
padding: 18px;
}
.widget-label {
color: var(--muted);
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
}
.widget-value {
font-size: 28px;
font-weight: 800;
letter-spacing: 0.3px;
color: var(--text);
}
.table-wrap {
overflow: hidden;
border-radius: 12px;
border: 1px solid var(--border);
}
.table {
width: 100%;
border-collapse: collapse;
background: transparent;
}
.table thead th {
text-align: left;
font-weight: 600;
color: var(--muted);
font-size: 13px;
letter-spacing: 0.3px;
background: #f9fafb;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.table tbody td {
padding: 14px;
border-bottom: 1px solid #f1f5f9;
}
.table tbody tr:hover td {
background: #f9fbff;
transition: background 0.15s ease;
}
.form {
display: grid;
gap: 16px;
}
.form-group {
display: grid;
gap: 8px;
}
.form-group label {
font-weight: 600;
color: #334155;
font-size: 14px;
}
.form-group input,
.form-group textarea {
width: 100%;
color: var(--text);
background: #ffffff;
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
font-size: 15px;
transition: box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.form-group textarea {
resize: vertical;
min-height: 160px;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #a7c2ff;
box-shadow: 0 0 0 4px var(--ring);
background: #ffffff;
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.button {
appearance: none;
border: 1px solid var(--border);
background: #ffffff;
color: var(--text);
border-radius: 12px;
padding: 10px 14px;
cursor: pointer;
font-weight: 600;
transition: transform 0.05s ease, background 0.2s ease, border 0.2s ease;
}
.button:hover {
background: #f5f7fb;
}
.button:active {
transform: translateY(1px);
}
.button-primary {
color: #ffffff;
background: linear-gradient(180deg, #3b82f6, var(--primary));
border-color: rgba(37, 99, 235, 0.4);
}
.button-primary:hover {
background: linear-gradient(180deg, #2f74ed, var(--primary-hover));
}
.button-secondary {
background: #ffffff;
}
.flash-stack {
display: grid;
gap: 10px;
margin-bottom: 18px;
}
.flash {
border-radius: 12px;
padding: 12px 14px;
font-weight: 600;
border: 1px solid var(--border);
background: #ffffff;
}
.flash-success {
border-color: #a7f3d0;
background: #ecfdf5;
color: #065f46;
}
.flash-danger,
.flash-error {
border-color: #fecaca;
background: #fef2f2;
color: #991b1b;
}
.flash-warning {
border-color: #fde68a;
background: #fffbeb;
color: #92400e;
}
.auth-wrapper {
display: grid;
place-items: center;
min-height: calc(100vh - 120px);
padding-top: 40px;
}
.auth-card {
max-width: 420px;
width: 100%;
}
@media (max-width: 900px) {
.widgets {
grid-template-columns: 1fr;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background-color: #f2f2f2;
}
a {
margin-right: 10px;
}
form {
max-width: 600px;
margin: 0 auto;
}
label {
display: block;
margin-top: 15px;
}
input[type="text"],
input[type="password"],
textarea {
width: 100%;
padding: 8px;
}
button {
margin-top: 15px;
padding: 10px 20px;
}
.flash {
background-color: #f8d7da;
color: #721c24;
padding: 10px;
margin-bottom: 10px;
text-align: center;
}
}

View file

@ -1,868 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Newsletter Admin Dashboard</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);
}
.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>
<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>
<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 %}
<!-- Statistics Cards -->
<div class="stats-grid">
<div class="stat-card success">
<div class="stat-header">
<div>
<div class="stat-number">{{ stats.total_active }}</div>
<div class="stat-label">Active Subscribers</div>
</div>
<div class="stat-icon success">
<i class="fas fa-users"></i>
</div>
</div>
</div>
<div class="stat-card info">
<div class="stat-header">
<div>
<div class="stat-number">{{ stats.recent_signups }}</div>
<div class="stat-label">New This Month</div>
</div>
<div class="stat-icon info">
<i class="fas fa-user-plus"></i>
</div>
</div>
</div>
<div class="stat-card primary">
<div class="stat-header">
<div>
<div class="stat-number">{{ stats.newsletters_sent }}</div>
<div class="stat-label">Newsletters Sent</div>
</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>
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">Dashboard</h1>
<p class="page-subtitle">Quick overview of your mailing activity</p>
</div>
<div class="page-actions">
<a href="{{ url_for('send_update') }}" class="button button-primary">Send Update</a>
</div>
</div>
<script>
async function addSubscriber() {
const emailInput = document.getElementById('newEmail');
const email = emailInput.value.trim();
<section class="widgets">
<div class="widget-card">
<div class="widget-label">Total Subscribers</div>
<div class="widget-value">{{ counts.total_subscribers }}</div>
</div>
<div class="widget-card">
<div class="widget-label">Newsletters Sent</div>
<div class="widget-value">{{ counts.total_newsletters }}</div>
</div>
<div class="widget-card">
<div class="widget-label">Sent Today</div>
<div class="widget-value">{{ counts.sent_today }}</div>
</div>
</section>
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>
</html>
{% if emails %}
<div class="card">
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Email Address</th>
</tr>
</thead>
<tbody>
{% for email in emails %}
<tr>
<td>{{ email }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="card empty-state">
<p>No subscribers found.</p>
</div>
{% endif %}
{% endblock %}

58
templates/base.html Normal file
View file

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>{% block title %}Admin{% endblock %}</title>
<link
rel="stylesheet"
href="{{ url_for('static', filename='css/style.css') }}"
/>
<link
rel="preconnect"
href="https://fonts.googleapis.com"
crossorigin
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<header class="navbar">
<div class="navbar-content">
<a class="brand" href="{{ url_for('index') }}">Admin Panel</a>
<nav class="navbar-links">
{% if session.get('username') %}
<a href="{{ url_for('index') }}">Dashboard</a>
<a href="{{ url_for('send_update') }}">Send Update</a>
<a href="{{ url_for('logout') }}" class="logout">Logout</a>
{% endif %}
</nav>
</div>
</header>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-stack">
{% for category, message in messages %}
<div class="flash flash-{{ category|lower }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="footer">
<div class="footer-inner">
<span>&copy; {{ 2025 }} Admin Panel</span>
</div>
</footer>
</body>
</html>

View file

@ -1,383 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - 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;
}
{% extends "base.html" %}
{% block title %}Admin Login{% endblock %}
{% block content %}
<section class="auth-wrapper">
<div class="card auth-card">
<h1 class="page-title">Welcome back</h1>
<p class="page-subtitle">Sign in to manage your subscribers</p>
: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>
<body>
<div class="login-container">
<div class="login-header">
<div class="login-icon">
<i class="fas fa-envelope"></i>
</div>
<h1 class="login-title">Newsletter Admin</h1>
<p class="login-subtitle">Sign in to manage your newsletter</p>
<form action="{{ url_for('login') }}" method="POST" class="form">
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
name="username"
id="username"
autocomplete="username"
required
/>
</div>
<!-- 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 class="form-group">
<label for="password">Password</label>
<input
type="password"
name="password"
id="password"
autocomplete="current-password"
required
/>
</div>
<button type="submit" class="button button-primary">Login</button>
</form>
</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>
</section>
{% endblock %}

View file

@ -1,667 +0,0 @@
<!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

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Send Update{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">Send Update</h1>
<p class="page-subtitle">Send an email update to all subscribers</p>
</div>
<div class="page-actions">
<a href="{{ url_for('index') }}" class="button button-secondary">Dashboard</a>
</div>
</div>
<div class="card">
<form action="{{ url_for('send_update') }}" method="POST" class="form">
<div class="form-group">
<label for="subject">Subject</label>
<input type="text" name="subject" id="subject" required />
</div>
<div class="form-group">
<label for="body">Body (HTML allowed)</label>
<textarea name="body" id="body" rows="12" required
placeholder="<h1>Title</h1><p>Your content...</p>"></textarea>
</div>
<div class="form-actions">
<button type="submit" class="button button-primary">Send Update</button>
</div>
</form>
</div>
{% endblock %}