diff --git a/Dockerfile b/Dockerfile
index 5d0ac86..88ff350 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,18 +1,30 @@
-FROM python:3.11-slim-buster
+# Use an official Python runtime as a base
+FROM python:3.11-slim-bookworm
-# Install build dependencies (build-essential provides gcc and other tools)
-RUN apt-get update && apt-get install -y build-essential
+# Set working directory
+WORKDIR /app
-WORKDIR /rideaware_landing
+# Install system dependencies
+RUN apt-get update && apt-get install -y \
+ build-essential \
+ libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+# Copy requirements first to leverage Docker cache
COPY requirements.txt .
+# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
+# Copy application code
COPY . .
-ENV FLASK_APP=server.py
+# Environment variables
+ENV FLASK_APP=app.py
+ENV FLASK_ENV=production
+ENV ENVIRONMENT=production
EXPOSE 5001
-CMD ["gunicorn", "--bind", "0.0.0.0:5001", "app:app"]
+# Use Gunicorn as production server
+CMD ["gunicorn", "--bind", "0.0.0.0:5001", "--workers", "4", "--timeout", "120", "app:app"]
\ No newline at end of file
diff --git a/app.py b/app.py
index cb006ca..85c05bd 100644
--- a/app.py
+++ b/app.py
@@ -1,10 +1,9 @@
import os
-import logging
import smtplib
from email.mime.text import MIMEText
-from email.mime.multipart import MIMEMultipart
-import concurrent.futures
-from threading import Lock
+from functools import wraps
+from urllib.parse import urlparse, urljoin
+
from flask import (
Flask,
render_template,
@@ -13,402 +12,233 @@ from flask import (
url_for,
flash,
session,
- jsonify,
- abort
)
+from markupsafe import escape
from dotenv import load_dotenv
from werkzeug.security import check_password_hash
-from functools import wraps
-import re
+
from database import (
- get_db_connection, init_db, get_all_emails, get_admin, create_default_admin,
- get_subscriber_stats, add_email, remove_email, save_newsletter,
- update_newsletter_stats, log_email_delivery, get_recent_newsletters
+ get_connection,
+ init_db,
+ get_all_emails,
+ get_admin,
+ create_default_admin,
)
load_dotenv()
+
app = Flask(__name__)
app.secret_key = os.getenv("SECRET_KEY")
-base_url = os.getenv("BASE_URL")
+base_url = os.getenv("BASE_URL", "").strip().strip("/")
-# SMTP settings
SMTP_SERVER = os.getenv("SMTP_SERVER")
SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER)
-SENDER_NAME = os.getenv("SENDER_NAME", "Newsletter Admin")
-# Email sending configuration
-MAX_EMAIL_WORKERS = 5
-email_send_lock = Lock()
-
-# Logging setup
-logging.basicConfig(
- level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
-)
-logger = logging.getLogger(__name__)
-
-# Initialize the database and create default admin user
init_db()
create_default_admin()
-# Security decorators
+
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if "username" not in session:
- if request.is_json:
- return jsonify({"error": "Authentication required"}), 401
- flash("Please log in to access this page.", "warning")
- return redirect(url_for("login"))
+ next_url = request.full_path if request.query_string else request.path
+ return redirect(url_for("login", next=next_url))
return f(*args, **kwargs)
+
return decorated_function
-def validate_email(email):
- """Validate email format."""
- pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
- return re.match(pattern, email) is not None
-def send_single_email(email_data):
- """Send a single email (for thread pool execution)."""
- email, subject, body, newsletter_id = email_data
+def get_dashboard_counts():
+ """Return dict of counts: total subscribers, total newsletters, sent today."""
+ counts = {"total_subscribers": 0, "total_newsletters": 0, "sent_today": 0}
try:
- server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=15)
- server.login(SMTP_USER, SMTP_PASSWORD)
+ conn = get_connection()
+ cur = conn.cursor()
+ cur.execute("SELECT COUNT(*) FROM subscribers")
+ counts["total_subscribers"] = cur.fetchone()[0] or 0
- # Create message
- msg = MIMEMultipart('alternative')
- msg['Subject'] = subject
- msg['From'] = f"{SENDER_NAME} <{SENDER_EMAIL}>"
- msg['To'] = email
+ cur.execute("SELECT COUNT(*) FROM newsletters")
+ counts["total_newsletters"] = cur.fetchone()[0] or 0
- # Create unsubscribe link
+ cur.execute(
+ """
+ SELECT COUNT(*)
+ FROM newsletters
+ WHERE sent_at::date = CURRENT_DATE
+ """
+ )
+ counts["sent_today"] = cur.fetchone()[0] or 0
+
+ cur.close()
+ conn.close()
+ except Exception:
+ pass
+ return counts
+
+
+def is_safe_url(target: str) -> bool:
+ if not target:
+ return False
+ ref = urlparse(request.host_url)
+ test = urlparse(urljoin(request.host_url, target))
+ return test.scheme in ("http", "https") and ref.netloc == test.netloc
+
+
+def send_update_email(subject: str, body_html: str, email: str) -> bool:
+ """Send a single HTML email with retries."""
+ max_retries = 3
+ retry_count = 0
+
+ unsub_link = ""
+ if base_url:
unsub_link = f"https://{base_url}/unsubscribe?email={email}"
-
- # HTML body with unsubscribe link
- html_body = f"""
-
-
- {body}
-
-
- If you wish to unsubscribe, please click here
-
-
-
- """
- # Attach HTML part
- html_part = MIMEText(html_body, 'html')
- msg.attach(html_part)
+ if unsub_link:
+ custom_body = (
+ f"{body_html}"
+ f"
"
+ f"If you ever wish to unsubscribe, please click "
+ f"here."
+ )
+ else:
+ custom_body = body_html
- server.sendmail(SENDER_EMAIL, email, msg.as_string())
- server.quit()
-
- # Log successful delivery
- if newsletter_id:
- log_email_delivery(newsletter_id, email, 'sent')
-
- logger.info(f"Email sent successfully to: {email}")
- return True, email, None
+ while retry_count < max_retries:
+ try:
+ server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
+ server.set_debuglevel(0)
+ server.login(SMTP_USER, SMTP_PASSWORD)
+
+ msg = MIMEText(custom_body, "html", "utf-8")
+ msg["Subject"] = subject
+ msg["From"] = SENDER_EMAIL
+ msg["To"] = email
+
+ server.sendmail(SENDER_EMAIL, [email], msg.as_string())
+ server.quit()
+ return True
+ except Exception:
+ retry_count += 1
+ if retry_count >= max_retries:
+ break
+ import time
+
+ time.sleep(1.0)
+
+ return False
+
+
+def process_send_update_email(subject: str, body_html: str) -> str:
+ """Send update email to all subscribers and log newsletter content."""
+ try:
+ subscribers = get_all_emails()
+ if not subscribers:
+ return "No subscribers found."
+
+ failures = []
+ for email in subscribers:
+ if not send_update_email(subject, body_html, email):
+ failures.append(email)
+
+ try:
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute(
+ "INSERT INTO newsletters (subject, body) VALUES (%s, %s)",
+ (subject, body_html),
+ )
+ conn.commit()
+ cursor.close()
+ conn.close()
+ except Exception:
+ pass
+
+ if failures:
+ return f"Sent with failures: {len(failures)} recipients failed."
+ return "Email has been sent to all subscribers."
except Exception as e:
- error_msg = str(e)
- logger.error(f"Failed to send email to {email}: {error_msg}")
-
- # Log failed delivery
- if newsletter_id:
- log_email_delivery(newsletter_id, email, 'failed', error_msg)
-
- return False, email, error_msg
+ return f"Failed to send email: {e}"
-def send_newsletter_batch(subject, body, email_list, newsletter_id=None):
- """Send newsletter to multiple recipients using thread pool."""
- success_count = 0
- failure_count = 0
- failed_emails = []
-
- # Prepare email data for thread pool
- email_data_list = [(email, subject, body, newsletter_id) for email in email_list]
-
- with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_EMAIL_WORKERS) as executor:
- future_to_email = {
- executor.submit(send_single_email, email_data): email_data[0]
- for email_data in email_data_list
- }
-
- for future in concurrent.futures.as_completed(future_to_email):
- success, email, error = future.result()
- if success:
- success_count += 1
- else:
- failure_count += 1
- failed_emails.append({'email': email, 'error': error})
-
- return success_count, failure_count, failed_emails
-@app.route("/")
+@app.route("/", methods=["GET"])
@login_required
def index():
- """Dashboard with subscriber statistics and recent activity."""
- page = request.args.get('page', 1, type=int)
- search = request.args.get('search', '')
- per_page = 25
-
- stats = get_subscriber_stats()
- subscribers_data = get_all_emails(page=page, per_page=per_page, search=search)
- recent_newsletters = get_recent_newsletters(limit=5)
-
- return render_template(
- "admin_index.html",
- stats=stats,
- subscribers=subscribers_data['subscribers'],
- pagination={
- 'page': subscribers_data['page'],
- 'per_page': subscribers_data['per_page'],
- 'total_pages': subscribers_data['total_pages'],
- 'total_count': subscribers_data['total_count']
- },
- search=search,
- recent_newsletters=recent_newsletters
- )
-
-@app.route("/subscribers")
-@login_required
-def subscribers():
- """Detailed subscriber management page."""
- page = request.args.get('page', 1, type=int)
- search = request.args.get('search', '')
- per_page = 50
-
- subscribers_data = get_all_emails(page=page, per_page=per_page, search=search)
-
- return render_template(
- "subscribers.html",
- subscribers=subscribers_data['subscribers'],
- pagination={
- 'page': subscribers_data['page'],
- 'per_page': subscribers_data['per_page'],
- 'total_pages': subscribers_data['total_pages'],
- 'total_count': subscribers_data['total_count']
- },
- search=search
- )
-
-@app.route("/add_subscriber", methods=["POST"])
-@login_required
-def add_subscriber():
- """Add a new subscriber via AJAX."""
+ """Dashboard: list subscriber emails and show widgets."""
+ emails = []
try:
- data = request.get_json()
- email = data.get('email', '').strip().lower()
-
- if not email or not validate_email(email):
- return jsonify({"success": False, "message": "Invalid email format"}), 400
-
- success = add_email(email, source='admin_manual')
-
- if success:
- return jsonify({"success": True, "message": f"Successfully added {email}"})
- else:
- return jsonify({"success": False, "message": "Email already exists or failed to add"}), 400
-
- except Exception as e:
- logger.error(f"Error adding subscriber: {e}")
- return jsonify({"success": False, "message": "Server error occurred"}), 500
+ emails = get_all_emails()
+ except Exception:
+ flash("Could not load subscribers right now.", "danger")
-@app.route("/remove_subscriber", methods=["POST"])
-@login_required
-def remove_subscriber():
- """Remove/unsubscribe a subscriber via AJAX."""
- try:
- data = request.get_json()
- email = data.get('email', '').strip().lower()
-
- if not email:
- return jsonify({"success": False, "message": "Email is required"}), 400
-
- success = remove_email(email)
-
- if success:
- return jsonify({"success": True, "message": f"Successfully unsubscribed {email}"})
- else:
- return jsonify({"success": False, "message": "Email not found"}), 404
-
- except Exception as e:
- logger.error(f"Error removing subscriber: {e}")
- return jsonify({"success": False, "message": "Server error occurred"}), 500
+ counts = get_dashboard_counts()
+ return render_template("admin_index.html", emails=emails, counts=counts)
-@app.route("/send_newsletter", methods=["GET", "POST"])
+
+@app.route("/send_update", methods=["GET", "POST"])
@login_required
-def send_newsletter():
- """Enhanced newsletter sending with preview and batch processing."""
+def send_update():
if request.method == "POST":
- action = request.form.get('action', 'send')
- subject = request.form.get('subject', '').strip()
- body = request.form.get('body', '').strip()
-
- if not subject or not body:
- flash("Subject and body are required", "error")
- return redirect(url_for('send_newsletter'))
-
- if action == 'preview':
- # Return preview
- preview_html = f"""
-
-
Subject: {subject}
-
{body}
-
-
Unsubscribe link will be automatically added to all emails
-
- """
- return render_template("send_newsletter.html", preview=preview_html, subject=subject, body=body)
-
- elif action == 'send':
- # Get all active subscribers (backwards compatible)
- try:
- with get_db_connection() as conn:
- cursor = conn.cursor()
-
- # Check if status column exists for backwards compatibility
- cursor.execute(
- """
- SELECT EXISTS (
- SELECT FROM information_schema.columns
- WHERE table_name = 'subscribers' AND column_name = 'status'
- )
- """
- )
- has_status = cursor.fetchone()[0]
-
- if has_status:
- cursor.execute("SELECT email FROM subscribers WHERE status = 'active'")
- else:
- cursor.execute("SELECT email FROM subscribers")
-
- email_list = [row[0] for row in cursor.fetchall()]
-
- except Exception as e:
- logger.error(f"Error fetching subscriber emails: {e}")
- flash("Error retrieving subscriber list", "error")
- return redirect(url_for('send_newsletter'))
-
- if not email_list:
- flash("No active subscribers found", "warning")
- return redirect(url_for('send_newsletter'))
-
- # Save newsletter to database
- newsletter_id = save_newsletter(subject, body, session['username'], len(email_list))
-
- # Send emails in batches
- try:
- success_count, failure_count, failed_emails = send_newsletter_batch(
- subject, body, email_list, newsletter_id
- )
-
- # Update newsletter statistics
- if newsletter_id:
- update_newsletter_stats(newsletter_id, success_count, failure_count)
-
- # Flash results
- if success_count > 0:
- flash(f"Newsletter sent successfully to {success_count} subscribers!", "success")
-
- if failure_count > 0:
- flash(f"Failed to send to {failure_count} subscribers", "error")
- for failed in failed_emails[:5]: # Show first 5 failures
- flash(f"Failed: {failed['email']} - {failed['error'][:100]}", "warning")
-
- except Exception as e:
- logger.error(f"Error sending newsletter: {e}")
- flash(f"Error sending newsletter: {str(e)}", "error")
-
- return redirect(url_for('send_newsletter'))
-
- return render_template("send_newsletter.html")
+ subject = (request.form.get("subject") or "").strip()
+ body_html = request.form.get("body") or ""
+
+ if not subject or not body_html:
+ flash("Subject and body are required", "danger")
+ return redirect(url_for("send_update"))
+
+ result_message = process_send_update_email(subject, body_html)
+ flash(escape(result_message))
+ return redirect(url_for("send_update"))
+
+ return render_template("send_update.html")
-@app.route("/newsletter_history")
-@login_required
-def newsletter_history():
- """View newsletter sending history."""
- newsletters = get_recent_newsletters(limit=50)
- return render_template("newsletter_history.html", newsletters=newsletters)
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
- username = request.form.get("username", "").strip()
- password = request.form.get("password", "")
-
+ username = (request.form.get("username") or "").strip()
+ password = (request.form.get("password") or "").strip()
+
if not username or not password:
- flash("Username and password are required", "error")
+ flash("Username and password are required", "danger")
return redirect(url_for("login"))
-
+
admin = get_admin(username)
- if admin and len(admin) >= 3 and check_password_hash(admin[2], password):
- if len(admin) >= 4 and not admin[3]: # Check is_active
- flash("Account is disabled", "error")
- return redirect(url_for("login"))
-
+ if admin and check_password_hash(admin[1], password):
session["username"] = username
- session["admin_id"] = admin[0]
- flash("Logged in successfully!", "success")
-
- # Redirect to intended page or dashboard
- next_page = request.args.get('next')
- if next_page:
- return redirect(next_page)
+ session.permanent = True
+ app.config["SESSION_COOKIE_HTTPONLY"] = True
+ app.config["SESSION_COOKIE_SECURE"] = True
+ app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
+
+ next_url = request.args.get("next")
+ if next_url and is_safe_url(next_url):
+ flash("Logged in successfully", "success")
+ return redirect(next_url)
+
+ flash("Logged in successfully", "success")
return redirect(url_for("index"))
else:
- flash("Invalid username or password", "error")
+ flash("Invalid username or password", "danger")
return redirect(url_for("login"))
-
+
return render_template("login.html")
-@app.route("/logout")
+
+@app.route("/logout", methods=["GET"])
def logout():
- session.clear()
- flash("You have been logged out successfully", "info")
+ session.pop("username", None)
+ flash("Logged out successfully", "success")
return redirect(url_for("login"))
-# Public unsubscribe endpoint
-@app.route("/unsubscribe")
-def unsubscribe():
- """Public unsubscribe endpoint."""
- email = request.args.get('email', '').strip().lower()
-
- if not email or not validate_email(email):
- return render_template("unsubscribe.html", error="Invalid email address")
-
- success = remove_email(email)
-
- if success:
- return render_template("unsubscribe.html", success=True, email=email)
- else:
- return render_template("unsubscribe.html", error="Email not found or already unsubscribed")
-
-# API endpoints for AJAX requests
-@app.route("/api/stats")
-@login_required
-def api_stats():
- """API endpoint for dashboard statistics."""
- stats = get_subscriber_stats()
- return jsonify(stats)
-
-# Error handlers
-@app.errorhandler(404)
-def not_found(error):
- return render_template("error.html", error="Page not found", code=404), 404
-
-@app.errorhandler(500)
-def internal_error(error):
- logger.error(f"Internal error: {error}")
- return render_template("error.html", error="Internal server error", code=500), 500
-
-# Context processors
-@app.context_processor
-def inject_user():
- return dict(current_user=session.get('username'))
if __name__ == "__main__":
- app.run(host="0.0.0.0", port=5001, debug=True)
\ No newline at end of file
+ is_prod = os.getenv("ENVIRONMENT", "development").lower() == "production"
+ app.config["PREFERRED_URL_SCHEME"] = "https" if is_prod else "http"
+ if is_prod:
+ app.run(host="0.0.0.0", port=5001, debug=False, use_reloader=False)
+ else:
+ app.run(host="0.0.0.0", port=5001, debug=True, use_reloader=True)
\ No newline at end of file
diff --git a/database.py b/database.py
index f0832c7..e5a85fb 100644
--- a/database.py
+++ b/database.py
@@ -1,522 +1,234 @@
import os
-import logging
import psycopg2
-from psycopg2 import IntegrityError, pool
+from psycopg2 import IntegrityError, pool, OperationalError
from dotenv import load_dotenv
from werkzeug.security import generate_password_hash
-from contextlib import contextmanager
-from datetime import datetime, timezone
load_dotenv()
-# Logging setup
-logging.basicConfig(
- level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
-)
-logger = logging.getLogger(__name__)
+try:
+ DB_MIN_CONN = int(os.getenv("DB_MIN_CONN", 1))
+ DB_MAX_CONN = int(os.getenv("DB_MAX_CONN", 10))
-# Connection pool for better performance
-connection_pool = None
-
-def init_connection_pool():
- """Initialize the connection pool."""
- global connection_pool
- try:
- connection_pool = psycopg2.pool.ThreadedConnectionPool(
- 1, 20, # min and max connections
- host=os.getenv("PG_HOST"),
- port=os.getenv("PG_PORT"),
- dbname=os.getenv("PG_DATABASE"),
- user=os.getenv("PG_USER"),
- password=os.getenv("PG_PASSWORD"),
- )
- logger.info("Connection pool created successfully")
- except Exception as e:
- logger.error(f"Connection pool creation error: {e}")
- raise
-
-@contextmanager
-def get_db_connection():
- """Context manager for database connections."""
- if connection_pool is None:
- init_connection_pool()
-
- conn = None
- try:
- conn = connection_pool.getconn()
- yield conn
- except Exception as e:
- if conn:
- conn.rollback()
- logger.error(f"Database operation error: {e}")
- raise
- finally:
- if conn:
- connection_pool.putconn(conn)
-
-def check_column_exists(cursor, table_name, column_name):
- """Check if a column exists in a table."""
- cursor.execute(
- """
- SELECT EXISTS (
- SELECT FROM information_schema.columns
- WHERE table_name = %s AND column_name = %s
- )
- """,
- (table_name, column_name)
+ conn_pool = pool.ThreadedConnectionPool(
+ minconn=DB_MIN_CONN,
+ maxconn=DB_MAX_CONN,
+ host=os.getenv("PG_HOST"),
+ port=os.getenv("PG_PORT"),
+ dbname=os.getenv("PG_DATABASE"),
+ user=os.getenv("PG_USER"),
+ password=os.getenv("PG_PASSWORD"),
+ connect_timeout=10,
)
- return cursor.fetchone()[0]
+except OperationalError:
+ raise
+except Exception:
+ raise
+
+
+def get_connection():
+ """Get a connection from the connection pool."""
+ return conn_pool.getconn()
-def migrate_database(conn):
- """Apply database migrations to upgrade existing schema."""
- cursor = conn.cursor()
-
- # Migration 1: Add new columns to subscribers table
- if not check_column_exists(cursor, 'subscribers', 'subscribed_at'):
- logger.info("Adding subscribed_at column to subscribers table")
- cursor.execute(
- "ALTER TABLE subscribers ADD COLUMN subscribed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP"
- )
-
- if not check_column_exists(cursor, 'subscribers', 'status'):
- logger.info("Adding status column to subscribers table")
- cursor.execute(
- "ALTER TABLE subscribers ADD COLUMN status TEXT DEFAULT 'active'"
- )
- # Add check constraint
- cursor.execute(
- "ALTER TABLE subscribers ADD CONSTRAINT subscribers_status_check CHECK (status IN ('active', 'unsubscribed'))"
- )
- # Update existing rows
- cursor.execute("UPDATE subscribers SET status = 'active' WHERE status IS NULL")
-
- if not check_column_exists(cursor, 'subscribers', 'source'):
- logger.info("Adding source column to subscribers table")
- cursor.execute(
- "ALTER TABLE subscribers ADD COLUMN source TEXT DEFAULT 'manual'"
- )
-
- # Migration 2: Add new columns to admin_users table
- if not check_column_exists(cursor, 'admin_users', 'created_at'):
- logger.info("Adding created_at column to admin_users table")
- cursor.execute(
- "ALTER TABLE admin_users ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP"
- )
-
- if not check_column_exists(cursor, 'admin_users', 'last_login'):
- logger.info("Adding last_login column to admin_users table")
- cursor.execute(
- "ALTER TABLE admin_users ADD COLUMN last_login TIMESTAMP WITH TIME ZONE"
- )
-
- if not check_column_exists(cursor, 'admin_users', 'is_active'):
- logger.info("Adding is_active column to admin_users table")
- cursor.execute(
- "ALTER TABLE admin_users ADD COLUMN is_active BOOLEAN DEFAULT TRUE"
- )
-
- # Migration 3: Add new columns to newsletters table
- if not check_column_exists(cursor, 'newsletters', 'sent_by'):
- logger.info("Adding sent_by column to newsletters table")
- cursor.execute(
- "ALTER TABLE newsletters ADD COLUMN sent_by TEXT"
- )
-
- if not check_column_exists(cursor, 'newsletters', 'recipient_count'):
- logger.info("Adding recipient_count column to newsletters table")
- cursor.execute(
- "ALTER TABLE newsletters ADD COLUMN recipient_count INTEGER DEFAULT 0"
- )
-
- if not check_column_exists(cursor, 'newsletters', 'success_count'):
- logger.info("Adding success_count column to newsletters table")
- cursor.execute(
- "ALTER TABLE newsletters ADD COLUMN success_count INTEGER DEFAULT 0"
- )
-
- if not check_column_exists(cursor, 'newsletters', 'failure_count'):
- logger.info("Adding failure_count column to newsletters table")
- cursor.execute(
- "ALTER TABLE newsletters ADD COLUMN failure_count INTEGER DEFAULT 0"
- )
-
- conn.commit()
- logger.info("Database migrations completed successfully")
def init_db():
- """Initialize the database tables with improved schema."""
+ """Initialize database tables with connection pool."""
+ conn = None
+ cursor = None
try:
- with get_db_connection() as conn:
- cursor = conn.cursor()
+ conn = get_connection()
+ cursor = conn.cursor()
- # Create basic subscribers table (backwards compatible)
- cursor.execute(
- """
- CREATE TABLE IF NOT EXISTS subscribers (
- id SERIAL PRIMARY KEY,
- email TEXT UNIQUE NOT NULL
- )
+ cursor.execute(
"""
+ CREATE TABLE IF NOT EXISTS subscribers (
+ id SERIAL PRIMARY KEY,
+ email TEXT UNIQUE NOT NULL
)
-
- # Create basic admin_users table (backwards compatible)
- cursor.execute(
- """
- CREATE TABLE IF NOT EXISTS admin_users (
- id SERIAL PRIMARY KEY,
- username TEXT UNIQUE NOT NULL,
- password TEXT NOT NULL
- )
"""
- )
+ )
- # Create basic newsletters table (backwards compatible)
- cursor.execute(
- """
- CREATE TABLE IF NOT EXISTS newsletters (
- id SERIAL PRIMARY KEY,
- subject TEXT NOT NULL,
- body TEXT NOT NULL,
- sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
- )
+ cursor.execute(
"""
+ CREATE TABLE IF NOT EXISTS admin_users (
+ id SERIAL PRIMARY KEY,
+ username TEXT UNIQUE NOT NULL,
+ password TEXT NOT NULL
)
-
- # Email delivery tracking (new table)
- cursor.execute(
- """
- CREATE TABLE IF NOT EXISTS email_deliveries (
- id SERIAL PRIMARY KEY,
- newsletter_id INTEGER REFERENCES newsletters(id),
- email TEXT NOT NULL,
- status TEXT CHECK (status IN ('sent', 'failed', 'bounced')),
- sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
- error_message TEXT
- )
"""
- )
+ )
- conn.commit()
- logger.info("Basic database tables created successfully")
-
- # Apply migrations to upgrade schema
- migrate_database(conn)
-
- # Add indexes after migrations are complete
- try:
- cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email)")
- cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscribers_status ON subscribers(status)")
- cursor.execute("CREATE INDEX IF NOT EXISTS idx_newsletters_sent_at ON newsletters(sent_at)")
- conn.commit()
- logger.info("Database indexes created successfully")
- except Exception as e:
- logger.warning(f"Some indexes may not have been created: {e}")
- # Continue anyway as this is not critical
-
- logger.info("Database initialization completed successfully")
-
- except Exception as e:
- logger.error(f"Database initialization error: {e}")
+ cursor.execute(
+ """
+ CREATE TABLE IF NOT EXISTS newsletters (
+ id SERIAL PRIMARY KEY,
+ subject TEXT NOT NULL,
+ body TEXT NOT NULL,
+ sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+ )
+ """
+ )
+
+ conn.commit()
+ except Exception:
+ if conn:
+ conn.rollback()
raise
+ finally:
+ if cursor:
+ cursor.close()
+ if conn:
+ conn_pool.putconn(conn)
-def get_subscriber_stats():
- """Get comprehensive subscriber statistics."""
+
+def get_all_emails():
+ """Return a list of all subscriber emails."""
+ conn = None
+ cursor = None
try:
- with get_db_connection() as conn:
- cursor = conn.cursor()
-
- # Check if status column exists
- has_status = check_column_exists(cursor, 'subscribers', 'status')
-
- if has_status:
- # Get total subscribers with status filtering
- cursor.execute("SELECT COUNT(*) FROM subscribers WHERE status = 'active'")
- total_active = cursor.fetchone()[0]
-
- cursor.execute("SELECT COUNT(*) FROM subscribers WHERE status = 'unsubscribed'")
- total_unsubscribed = cursor.fetchone()[0]
-
- # Get recent signups (last 30 days) - check if subscribed_at exists
- has_subscribed_at = check_column_exists(cursor, 'subscribers', 'subscribed_at')
- if has_subscribed_at:
- cursor.execute("""
- SELECT COUNT(*) FROM subscribers
- WHERE subscribed_at >= NOW() - INTERVAL '30 days' AND status = 'active'
- """)
- recent_signups = cursor.fetchone()[0]
- else:
- recent_signups = 0
- else:
- # Fallback for old schema
- cursor.execute("SELECT COUNT(*) FROM subscribers")
- total_active = cursor.fetchone()[0]
- total_unsubscribed = 0
- recent_signups = 0
-
- # Get newsletters sent
- cursor.execute("SELECT COUNT(*) FROM newsletters")
- newsletters_sent = cursor.fetchone()[0]
-
- return {
- 'total_active': total_active,
- 'total_unsubscribed': total_unsubscribed,
- 'recent_signups': recent_signups,
- 'newsletters_sent': newsletters_sent
- }
- except Exception as e:
- logger.error(f"Error retrieving subscriber stats: {e}")
- return {'total_active': 0, 'total_unsubscribed': 0, 'recent_signups': 0, 'newsletters_sent': 0}
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute("SELECT email FROM subscribers")
+ results = cursor.fetchall()
+ return [row[0] for row in results]
+ except Exception:
+ return []
+ finally:
+ if cursor:
+ cursor.close()
+ if conn:
+ conn_pool.putconn(conn)
-def get_all_emails(page=1, per_page=50, search=''):
- """Return paginated list of subscriber emails with search functionality."""
- try:
- with get_db_connection() as conn:
- cursor = conn.cursor()
-
- # Check which columns exist
- has_status = check_column_exists(cursor, 'subscribers', 'status')
- has_subscribed_at = check_column_exists(cursor, 'subscribers', 'subscribed_at')
- has_source = check_column_exists(cursor, 'subscribers', 'source')
-
- # Calculate offset
- offset = (page - 1) * per_page
-
- # Build base query based on available columns
- if has_status:
- base_query = "FROM subscribers WHERE status = 'active'"
- else:
- base_query = "FROM subscribers WHERE 1=1"
-
- params = []
-
- if search:
- base_query += " AND email ILIKE %s"
- params.append(f"%{search}%")
-
- # Get total count
- cursor.execute(f"SELECT COUNT(*) {base_query}", params)
- total_count = cursor.fetchone()[0]
-
- # Build select query based on available columns
- select_fields = ["id", "email"]
- if has_subscribed_at:
- select_fields.append("subscribed_at")
- if has_source:
- select_fields.append("source")
-
- # Get paginated results
- query = f"""
- SELECT {', '.join(select_fields)}
- {base_query}
- ORDER BY {"subscribed_at DESC" if has_subscribed_at else "id DESC"}
- LIMIT %s OFFSET %s
- """
- params.extend([per_page, offset])
- cursor.execute(query, params)
-
- results = cursor.fetchall()
- subscribers = []
-
- for row in results:
- subscriber = {
- 'id': row[0],
- 'email': row[1],
- 'subscribed_at': row[2] if has_subscribed_at and len(row) > 2 else None,
- 'source': row[3] if has_source and len(row) > 3 else 'manual'
- }
- subscribers.append(subscriber)
-
- return {
- 'subscribers': subscribers,
- 'total_count': total_count,
- 'page': page,
- 'per_page': per_page,
- 'total_pages': (total_count + per_page - 1) // per_page
- }
- except Exception as e:
- logger.error(f"Error retrieving emails: {e}")
- return {'subscribers': [], 'total_count': 0, 'page': 1, 'per_page': per_page, 'total_pages': 0}
-def add_email(email, source='manual'):
+def add_email(email):
"""Insert an email into the subscribers table."""
+ conn = None
+ cursor = None
try:
- with get_db_connection() as conn:
- cursor = conn.cursor()
-
- # Check if source column exists
- has_source = check_column_exists(cursor, 'subscribers', 'source')
-
- if has_source:
- cursor.execute(
- "INSERT INTO subscribers (email, source) VALUES (%s, %s)",
- (email.lower().strip(), source)
- )
- else:
- cursor.execute(
- "INSERT INTO subscribers (email) VALUES (%s)",
- (email.lower().strip(),)
- )
-
- conn.commit()
- logger.info(f"Email {email} added successfully.")
- return True
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,))
+ conn.commit()
+ return True
except IntegrityError:
- logger.warning(f"Attempted to add duplicate email: {email}")
+ if conn:
+ conn.rollback()
return False
- except Exception as e:
- logger.error(f"Error adding email {email}: {e}")
+ except Exception:
+ if conn:
+ conn.rollback()
return False
+ finally:
+ if cursor:
+ cursor.close()
+ if conn:
+ conn_pool.putconn(conn)
+
def remove_email(email):
- """Mark email as unsubscribed or delete if status column doesn't exist."""
+ """Remove an email from the subscribers table."""
+ conn = None
+ cursor = None
try:
- with get_db_connection() as conn:
- cursor = conn.cursor()
-
- # Check if status column exists
- has_status = check_column_exists(cursor, 'subscribers', 'status')
-
- if has_status:
- # Mark as unsubscribed
- cursor.execute(
- "UPDATE subscribers SET status = 'unsubscribed' WHERE email = %s",
- (email,)
- )
- else:
- # Delete the record (old behavior)
- cursor.execute(
- "DELETE FROM subscribers WHERE email = %s",
- (email,)
- )
-
- rowcount = cursor.rowcount
- conn.commit()
- logger.info(f"Email {email} {'unsubscribed' if has_status else 'removed'} successfully.")
- return rowcount > 0
- except Exception as e:
- logger.error(f"Error {'unsubscribing' if has_status else 'removing'} email {email}: {e}")
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,))
+ rowcount = cursor.rowcount
+ conn.commit()
+ return rowcount > 0
+ except Exception:
+ if conn:
+ conn.rollback()
return False
+ finally:
+ if cursor:
+ cursor.close()
+ if conn:
+ conn_pool.putconn(conn)
+
def get_admin(username):
- """Retrieve admin credentials and update last login."""
+ """Retrieve admin credentials for a given username.
+ Returns a tuple (username, password_hash) if found, otherwise None.
+ """
+ conn = None
+ cursor = None
try:
- with get_db_connection() as conn:
- cursor = conn.cursor()
- cursor.execute(
- """SELECT id, username, password, is_active
- FROM admin_users
- WHERE username = %s AND is_active = TRUE""",
- (username,),
- )
- result = cursor.fetchone()
-
- if result:
- # Update last login
- cursor.execute(
- "UPDATE admin_users SET last_login = %s WHERE id = %s",
- (datetime.now(timezone.utc), result[0])
- )
- conn.commit()
-
- return result
- except Exception as e:
- logger.error(f"Error retrieving admin: {e}")
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute(
+ "SELECT username, password FROM admin_users WHERE username = %s",
+ (username,),
+ )
+ return cursor.fetchone()
+ except Exception:
return None
+ finally:
+ if cursor:
+ cursor.close()
+ if conn:
+ conn_pool.putconn(conn)
+
def create_default_admin():
"""Create a default admin user if one doesn't already exist."""
default_username = os.getenv("ADMIN_USERNAME", "admin")
default_password = os.getenv("ADMIN_PASSWORD", "changeme")
hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256")
-
- try:
- with get_db_connection() as conn:
- cursor = conn.cursor()
- # Check if any admin exists
- cursor.execute("SELECT COUNT(*) FROM admin_users WHERE is_active = TRUE")
- admin_count = cursor.fetchone()[0]
-
- if admin_count == 0:
- cursor.execute(
- "INSERT INTO admin_users (username, password) VALUES (%s, %s)",
- (default_username, hashed_password),
- )
- conn.commit()
- logger.info("Default admin created successfully")
+ conn = None
+ cursor = None
+ try:
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ cursor.execute(
+ "SELECT id FROM admin_users WHERE username = %s", (default_username,)
+ )
+ exists = cursor.fetchone()
+ if exists is None:
+ cursor.execute(
+ "INSERT INTO admin_users (username, password) VALUES (%s, %s)",
+ (default_username, hashed_password),
+ )
+ conn.commit()
+ except Exception:
+ if conn:
+ conn.rollback()
+ finally:
+ if cursor:
+ cursor.close()
+ if conn:
+ conn_pool.putconn(conn)
+
+
+def close_pool():
+ """Close the database connection pool."""
+ try:
+ conn_pool.closeall()
+ except Exception:
+ pass
+
+
+class DatabaseContext:
+ """Optional context manager for manual transactions."""
+ def __init__(self):
+ self.conn = None
+ self.cursor = None
+
+ def __enter__(self):
+ self.conn = get_connection()
+ self.cursor = self.conn.cursor()
+ return self.cursor
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ try:
+ if exc_type:
+ self.conn.rollback()
else:
- logger.info("Admin users already exist")
- except Exception as e:
- logger.error(f"Error creating default admin: {e}")
-
-def save_newsletter(subject, body, sent_by, recipient_count=0):
- """Save newsletter to database and return the ID."""
- try:
- with get_db_connection() as conn:
- cursor = conn.cursor()
- cursor.execute(
- """INSERT INTO newsletters (subject, body, sent_by, recipient_count)
- VALUES (%s, %s, %s, %s) RETURNING id""",
- (subject, body, sent_by, recipient_count)
- )
- newsletter_id = cursor.fetchone()[0]
- conn.commit()
- return newsletter_id
- except Exception as e:
- logger.error(f"Error saving newsletter: {e}")
- return None
-
-def update_newsletter_stats(newsletter_id, success_count, failure_count):
- """Update newsletter delivery statistics."""
- try:
- with get_db_connection() as conn:
- cursor = conn.cursor()
- cursor.execute(
- """UPDATE newsletters
- SET success_count = %s, failure_count = %s
- WHERE id = %s""",
- (success_count, failure_count, newsletter_id)
- )
- conn.commit()
- except Exception as e:
- logger.error(f"Error updating newsletter stats: {e}")
-
-def log_email_delivery(newsletter_id, email, status, error_message=None):
- """Log individual email delivery attempt."""
- try:
- with get_db_connection() as conn:
- cursor = conn.cursor()
- cursor.execute(
- """INSERT INTO email_deliveries (newsletter_id, email, status, error_message)
- VALUES (%s, %s, %s, %s)""",
- (newsletter_id, email, status, error_message)
- )
- conn.commit()
- except Exception as e:
- logger.error(f"Error logging email delivery: {e}")
-
-def get_recent_newsletters(limit=10):
- """Get recent newsletters with statistics."""
- try:
- with get_db_connection() as conn:
- cursor = conn.cursor()
- cursor.execute(
- """SELECT id, subject, sent_at, sent_by, recipient_count, success_count, failure_count
- FROM newsletters
- ORDER BY sent_at DESC
- LIMIT %s""",
- (limit,)
- )
- results = cursor.fetchall()
- return [{
- 'id': row[0],
- 'subject': row[1],
- 'sent_at': row[2],
- 'sent_by': row[3],
- 'recipient_count': row[4],
- 'success_count': row[5],
- 'failure_count': row[6]
- } for row in results]
- except Exception as e:
- logger.error(f"Error retrieving recent newsletters: {e}")
- return []
\ No newline at end of file
+ self.conn.commit()
+ finally:
+ if self.cursor:
+ self.cursor.close()
+ if self.conn:
+ conn_pool.putconn(self.conn)
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 26fcaf5..3d2a2e9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,3 +3,6 @@ flask
python-dotenv
Werkzeug
psycopg2-binary
+psycopg2-pool
+python-decouple
+markupsafe
\ No newline at end of file
diff --git a/static/css/style.css b/static/css/style.css
index b9c3f88..9dca122 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -1,55 +1,306 @@
+:root {
+ --primary: #2563eb;
+ --primary-hover: #1d4ed8;
+ --bg: #f5f7fb;
+ --bg-grad-1: #f8fbff;
+ --bg-grad-2: #eef3fb;
+ --surface: #ffffff;
+ --text: #0f172a;
+ --muted: #64748b;
+ --border: #e5e7eb;
+ --ring: rgba(37, 99, 235, 0.25);
+ --radius: 14px;
+ --shadow-1: 0 8px 24px rgba(15, 23, 42, 0.08);
+ --shadow-2: 0 14px 38px rgba(15, 23, 42, 0.12);
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+html, body {
+ margin: 0;
+ padding: 0;
+}
body {
- font-family: Arial, sans-serif;
- padding: 20px;
+ font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
+ color: var(--text);
+ background: radial-gradient(1000px 650px at 0% 0%, var(--bg-grad-1), transparent 60%),
+ radial-gradient(900px 600px at 100% 0%, var(--bg-grad-2), transparent 55%),
+ var(--bg);
+ min-height: 100vh;
+}
+
+.navbar {
+ position: sticky;
+ top: 0;
+ z-index: 1000;
+ background: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(10px);
+ border-bottom: 1px solid rgba(15, 23, 42, 0.06);
+}
+.navbar-content {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 14px 20px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.brand {
+ color: var(--text);
+ font-weight: 700;
+ text-decoration: none;
+ letter-spacing: 0.2px;
+}
+.navbar-links a {
+ color: var(--muted);
+ text-decoration: none;
+ margin-left: 14px;
+ padding: 8px 12px;
+ border-radius: 10px;
+ transition: all 0.2s ease;
+}
+.navbar-links a:hover {
+ color: var(--text);
+ background: rgba(15, 23, 42, 0.05);
+}
+.navbar-links .logout {
+ color: #b91c1c;
+}
+.navbar-links .logout:hover {
+ background: rgba(185, 28, 28, 0.08);
+}
+
+.container {
+ max-width: 1100px;
+ margin: 28px auto 48px;
+ padding: 0 20px;
+ padding-bottom: 64px;
+}
+
+.footer {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1000;
+ border-top: 1px solid rgba(15, 23, 42, 0.06);
+ background: rgba(255, 255, 255, 0.92);
+ backdrop-filter: blur(8px);
+}
+
+.footer-inner {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 14px 20px;
+ color: var(--muted);
+ font-size: 14px;
+}
+
+.page-header {
+ margin-bottom: 18px;
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.page-title {
+ margin: 0 0 4px 0;
+ font-size: 26px;
+ font-weight: 700;
+}
+.page-subtitle {
+ margin: 0;
+ color: var(--muted);
+ font-size: 14px;
+}
+.page-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-1);
+ padding: 20px;
+}
+.empty-state {
+ text-align: center;
+ color: var(--muted);
+}
+
+.widgets {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+ margin-bottom: 18px;
+}
+.widget-card {
+ background: linear-gradient(180deg, #fff, #fafcff);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-1);
+ padding: 18px;
+}
+.widget-label {
+ color: var(--muted);
+ font-weight: 600;
+ font-size: 13px;
+ margin-bottom: 8px;
+}
+.widget-value {
+ font-size: 28px;
+ font-weight: 800;
+ letter-spacing: 0.3px;
+ color: var(--text);
+}
+
+.table-wrap {
+ overflow: hidden;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+}
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ background: transparent;
+}
+.table thead th {
+ text-align: left;
+ font-weight: 600;
+ color: var(--muted);
+ font-size: 13px;
+ letter-spacing: 0.3px;
+ background: #f9fafb;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--border);
+}
+.table tbody td {
+ padding: 14px;
+ border-bottom: 1px solid #f1f5f9;
+}
+.table tbody tr:hover td {
+ background: #f9fbff;
+ transition: background 0.15s ease;
+}
+
+.form {
+ display: grid;
+ gap: 16px;
+}
+.form-group {
+ display: grid;
+ gap: 8px;
+}
+.form-group label {
+ font-weight: 600;
+ color: #334155;
+ font-size: 14px;
+}
+.form-group input,
+.form-group textarea {
+ width: 100%;
+ color: var(--text);
+ background: #ffffff;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 12px 14px;
+ font-size: 15px;
+ transition: box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
+}
+.form-group textarea {
+ resize: vertical;
+ min-height: 160px;
+}
+.form-group input:focus,
+.form-group textarea:focus {
+ outline: none;
+ border-color: #a7c2ff;
+ box-shadow: 0 0 0 4px var(--ring);
+ background: #ffffff;
+}
+.form-actions {
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+}
+
+.button {
+ appearance: none;
+ border: 1px solid var(--border);
+ background: #ffffff;
+ color: var(--text);
+ border-radius: 12px;
+ padding: 10px 14px;
+ cursor: pointer;
+ font-weight: 600;
+ transition: transform 0.05s ease, background 0.2s ease, border 0.2s ease;
+}
+.button:hover {
+ background: #f5f7fb;
+}
+.button:active {
+ transform: translateY(1px);
+}
+.button-primary {
+ color: #ffffff;
+ background: linear-gradient(180deg, #3b82f6, var(--primary));
+ border-color: rgba(37, 99, 235, 0.4);
+}
+.button-primary:hover {
+ background: linear-gradient(180deg, #2f74ed, var(--primary-hover));
+}
+.button-secondary {
+ background: #ffffff;
+}
+
+.flash-stack {
+ display: grid;
+ gap: 10px;
+ margin-bottom: 18px;
+}
+.flash {
+ border-radius: 12px;
+ padding: 12px 14px;
+ font-weight: 600;
+ border: 1px solid var(--border);
+ background: #ffffff;
+}
+.flash-success {
+ border-color: #a7f3d0;
+ background: #ecfdf5;
+ color: #065f46;
+}
+.flash-danger,
+.flash-error {
+ border-color: #fecaca;
+ background: #fef2f2;
+ color: #991b1b;
+}
+.flash-warning {
+ border-color: #fde68a;
+ background: #fffbeb;
+ color: #92400e;
+}
+
+.auth-wrapper {
+ display: grid;
+ place-items: center;
+ min-height: calc(100vh - 120px);
+ padding-top: 40px;
+}
+.auth-card {
+ max-width: 420px;
+ width: 100%;
+}
+
+@media (max-width: 900px) {
+ .widgets {
+ grid-template-columns: 1fr;
}
-
- table {
- border-collapse: collapse;
- width: 100%;
- }
-
- th,
- td {
- border: 1px solid #ddd;
- padding: 8px;
- text-align: left;
- }
-
- th {
- background-color: #f2f2f2;
- }
-
- a {
- margin-right: 10px;
- }
-
- form {
- max-width: 600px;
- margin: 0 auto;
- }
-
- label {
- display: block;
- margin-top: 15px;
- }
-
- input[type="text"],
- input[type="password"],
- textarea {
- width: 100%;
- padding: 8px;
- }
-
- button {
- margin-top: 15px;
- padding: 10px 20px;
- }
-
- .flash {
- background-color: #f8d7da;
- color: #721c24;
- padding: 10px;
- margin-bottom: 10px;
- text-align: center;
- }
-
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/templates/admin_index.html b/templates/admin_index.html
index 2c3869a..bf57258 100644
--- a/templates/admin_index.html
+++ b/templates/admin_index.html
@@ -1,868 +1,53 @@
-
-
-
-
-
- Newsletter Admin Dashboard
-
-
-
-
-
-
-
-
- {% with messages = get_flashed_messages(with_categories=true) %}
- {% if messages %}
-
- {% for category, message in messages %}
-
- {% if category == 'success' %}
-
- {% elif category == 'error' %}
-
- {% elif category == 'warning' %}
-
- {% else %}
-
- {% endif %}
- {{ message }}
-
- {% endfor %}
-
- {% endif %}
- {% endwith %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% if subscribers %}
-
-
-
-
- | Email Address |
- Joined |
- Source |
- Actions |
-
-
-
- {% for subscriber in subscribers %}
-
- | {{ subscriber.email }} |
- {{ subscriber.subscribed_at.strftime('%b %d, %Y') if subscriber.subscribed_at else 'N/A' }} |
-
- {{ subscriber.source or 'manual' }}
- |
-
-
- |
-
- {% endfor %}
-
-
-
-
-
- {% if pagination.total_pages > 1 %}
-
- {% endif %}
- {% else %}
-
-
-
No subscribers yet
-
Start building your subscriber list!
-
- {% endif %}
-
-
-
-
-
-
-
- {% if recent_newsletters %}
-
- {% for newsletter in recent_newsletters %}
-
-
{{ newsletter.subject }}
-
- {{ newsletter.sent_at.strftime('%b %d, %Y at %H:%M') if newsletter.sent_at else 'N/A' }}
- {{ newsletter.sent_by or 'System' }}
- {% if newsletter.success_count is not none %}
- {{ newsletter.success_count }} sent
- {% endif %}
- {% if newsletter.failure_count and newsletter.failure_count > 0 %}
- {{ newsletter.failure_count }} failed
- {% endif %}
-
-
- {% endfor %}
-
- {% else %}
-
- {% endif %}
-
-
-
+{% extends "base.html" %}
+{% block title %}Dashboard{% endblock %}
+{% block content %}
+
-
-
-
\ No newline at end of file
+ {% if emails %}
+
+
+
+
+
+ | Email Address |
+
+
+
+ {% for email in emails %}
+
+ | {{ email }} |
+
+ {% endfor %}
+
+
+
+
+ {% else %}
+
+
No subscribers found.
+
+ {% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..13df2b0
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+
{% block title %}Admin{% endblock %}
+
+
+
+
+
+
+
+
+ {% with messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+
+ {% for category, message in messages %}
+
{{ message }}
+ {% endfor %}
+
+ {% endif %}
+ {% endwith %}
+
+ {% block content %}{% endblock %}
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/login.html b/templates/login.html
index 7325fb1..f70fb06 100644
--- a/templates/login.html
+++ b/templates/login.html
@@ -1,383 +1,34 @@
-
-
-
-
-
-
Admin Login - Newsletter Admin
-
-
-
-
-
-