Compare commits
	
		
			1 commit
		
	
	
		
			main
			...
			refactor/c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 69468dc5bf | 
					 6 changed files with 2686 additions and 295 deletions
				
			
		
							
								
								
									
										412
									
								
								app.py
									
										
									
									
									
								
							
							
						
						
									
										412
									
								
								app.py
									
										
									
									
									
								
							|  | @ -2,6 +2,9 @@ import os | ||||||
| import logging | import logging | ||||||
| import smtplib | import smtplib | ||||||
| from email.mime.text import MIMEText | from email.mime.text import MIMEText | ||||||
|  | from email.mime.multipart import MIMEMultipart | ||||||
|  | import concurrent.futures | ||||||
|  | from threading import Lock | ||||||
| from flask import ( | from flask import ( | ||||||
|     Flask, |     Flask, | ||||||
|     render_template, |     render_template, | ||||||
|  | @ -10,23 +13,35 @@ from flask import ( | ||||||
|     url_for, |     url_for, | ||||||
|     flash, |     flash, | ||||||
|     session, |     session, | ||||||
|  |     jsonify, | ||||||
|  |     abort | ||||||
| ) | ) | ||||||
| from dotenv import load_dotenv | from dotenv import load_dotenv | ||||||
| from werkzeug.security import check_password_hash | from werkzeug.security import check_password_hash | ||||||
| from functools import wraps  # Import wraps | from functools import wraps | ||||||
| from database import get_connection, init_db, get_all_emails, get_admin, create_default_admin | import re | ||||||
|  | from database import ( | ||||||
|  |     get_db_connection, init_db, get_all_emails, get_admin, create_default_admin, | ||||||
|  |     get_subscriber_stats, add_email, remove_email, save_newsletter, | ||||||
|  |     update_newsletter_stats, log_email_delivery, get_recent_newsletters | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| load_dotenv() | load_dotenv() | ||||||
| app = Flask(__name__) | app = Flask(__name__) | ||||||
| app.secret_key = os.getenv("SECRET_KEY") | app.secret_key = os.getenv("SECRET_KEY") | ||||||
| base_url = os.getenv("BASE_URL") | base_url = os.getenv("BASE_URL") | ||||||
| 
 | 
 | ||||||
| # SMTP settings (for sending update emails) | # SMTP settings | ||||||
| SMTP_SERVER = os.getenv("SMTP_SERVER") | SMTP_SERVER = os.getenv("SMTP_SERVER") | ||||||
| SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) | SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) | ||||||
| SMTP_USER = os.getenv("SMTP_USER") | SMTP_USER = os.getenv("SMTP_USER") | ||||||
| SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") | SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") | ||||||
| SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) # Use SENDER_EMAIL | SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) | ||||||
|  | SENDER_NAME = os.getenv("SENDER_NAME", "Newsletter Admin") | ||||||
|  | 
 | ||||||
|  | # Email sending configuration | ||||||
|  | MAX_EMAIL_WORKERS = 5 | ||||||
|  | email_send_lock = Lock() | ||||||
| 
 | 
 | ||||||
| # Logging setup | # Logging setup | ||||||
| logging.basicConfig( | logging.basicConfig( | ||||||
|  | @ -34,117 +49,366 @@ logging.basicConfig( | ||||||
| ) | ) | ||||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||||
| 
 | 
 | ||||||
| # Initialize the database and create default admin user if necessary. | # Initialize the database and create default admin user | ||||||
| init_db() | init_db() | ||||||
| create_default_admin() | create_default_admin() | ||||||
| 
 | 
 | ||||||
| # Decorator for requiring login | # Security decorators | ||||||
| def login_required(f): | def login_required(f): | ||||||
|     @wraps(f)  # Use wraps to preserve function metadata |     @wraps(f) | ||||||
|     def decorated_function(*args, **kwargs): |     def decorated_function(*args, **kwargs): | ||||||
|         if "username" not in session: |         if "username" not in session: | ||||||
|  |             if request.is_json: | ||||||
|  |                 return jsonify({"error": "Authentication required"}), 401 | ||||||
|  |             flash("Please log in to access this page.", "warning") | ||||||
|             return redirect(url_for("login")) |             return redirect(url_for("login")) | ||||||
|         return f(*args, **kwargs) |         return f(*args, **kwargs) | ||||||
| 
 |  | ||||||
|     return decorated_function |     return decorated_function | ||||||
| 
 | 
 | ||||||
|  | def validate_email(email): | ||||||
|  |     """Validate email format.""" | ||||||
|  |     pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' | ||||||
|  |     return re.match(pattern, email) is not None | ||||||
| 
 | 
 | ||||||
| def send_update_email(subject, body, email): | def send_single_email(email_data): | ||||||
|     """Sends email, returns True on success, False on failure.""" |     """Send a single email (for thread pool execution).""" | ||||||
|  |     email, subject, body, newsletter_id = email_data | ||||||
|     try: |     try: | ||||||
|         server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) |         server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=15) | ||||||
|         server.set_debuglevel(False)  # Keep debug level at False for production |  | ||||||
|         server.login(SMTP_USER, SMTP_PASSWORD) |         server.login(SMTP_USER, SMTP_PASSWORD) | ||||||
| 
 | 
 | ||||||
|  |         # Create message | ||||||
|  |         msg = MIMEMultipart('alternative') | ||||||
|  |         msg['Subject'] = subject | ||||||
|  |         msg['From'] = f"{SENDER_NAME} <{SENDER_EMAIL}>" | ||||||
|  |         msg['To'] = email | ||||||
|  | 
 | ||||||
|  |         # Create unsubscribe link | ||||||
|         unsub_link = f"https://{base_url}/unsubscribe?email={email}" |         unsub_link = f"https://{base_url}/unsubscribe?email={email}" | ||||||
|         custom_body = ( |          | ||||||
|             f"{body}<br><br>" |         # HTML body with unsubscribe link | ||||||
|             f"If you ever wish to unsubscribe, please click <a href='{unsub_link}'>here</a>" |         html_body = f""" | ||||||
|         ) |         <html> | ||||||
|  |         <body> | ||||||
|  |         {body} | ||||||
|  |         <hr style="margin-top: 40px; border: none; border-top: 1px solid #eee;"> | ||||||
|  |         <p style="font-size: 12px; color: #888; text-align: center;"> | ||||||
|  |         If you wish to unsubscribe, please <a href="{unsub_link}" style="color: #888;">click here</a> | ||||||
|  |         </p> | ||||||
|  |         </body> | ||||||
|  |         </html> | ||||||
|  |         """ | ||||||
| 
 | 
 | ||||||
|         msg = MIMEText(custom_body, "html", "utf-8") |         # Attach HTML part | ||||||
|         msg["Subject"] = subject |         html_part = MIMEText(html_body, 'html') | ||||||
|         msg["From"] = SENDER_EMAIL  # Use sender email |         msg.attach(html_part) | ||||||
|         msg["To"] = email |  | ||||||
| 
 |  | ||||||
|         server.sendmail(SENDER_EMAIL, email, msg.as_string())  # Use sender email |  | ||||||
| 
 | 
 | ||||||
|  |         server.sendmail(SENDER_EMAIL, email, msg.as_string()) | ||||||
|         server.quit() |         server.quit() | ||||||
|         logger.info(f"Update email sent to: {email}") |          | ||||||
|         return True |         # Log successful delivery | ||||||
|  |         if newsletter_id: | ||||||
|  |             log_email_delivery(newsletter_id, email, 'sent') | ||||||
|  |          | ||||||
|  |         logger.info(f"Email sent successfully to: {email}") | ||||||
|  |         return True, email, None | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         logger.error(f"Failed to send email to {email}: {e}") |         error_msg = str(e) | ||||||
|         return False |         logger.error(f"Failed to send email to {email}: {error_msg}") | ||||||
| 
 |          | ||||||
| 
 |         # Log failed delivery | ||||||
| def process_send_update_email(subject, body): |         if newsletter_id: | ||||||
|     """Helper function to send an update email to all subscribers.""" |             log_email_delivery(newsletter_id, email, 'failed', error_msg) | ||||||
|     subscribers = get_all_emails() |          | ||||||
|     if not subscribers: |         return False, email, error_msg | ||||||
|         return "No subscribers found." |  | ||||||
|     try: |  | ||||||
|         for email in subscribers: |  | ||||||
|             if not send_update_email(subject, body, email): |  | ||||||
|                 return f"Failed to send to {email}"  # Specific failure message |  | ||||||
| 
 |  | ||||||
|         # Log newsletter content for audit purposes |  | ||||||
|         conn = get_connection() |  | ||||||
|         cursor = conn.cursor() |  | ||||||
|         cursor.execute( |  | ||||||
|             "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", (subject, body) |  | ||||||
|         ) |  | ||||||
|         conn.commit() |  | ||||||
|         cursor.close() |  | ||||||
|         conn.close() |  | ||||||
| 
 |  | ||||||
|         return "Email has been sent to all subscribers." |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.exception("Error processing sending updates") |  | ||||||
|         return f"Failed to send email: {e}" |  | ||||||
| 
 | 
 | ||||||
|  | def send_newsletter_batch(subject, body, email_list, newsletter_id=None): | ||||||
|  |     """Send newsletter to multiple recipients using thread pool.""" | ||||||
|  |     success_count = 0 | ||||||
|  |     failure_count = 0 | ||||||
|  |     failed_emails = [] | ||||||
|  |      | ||||||
|  |     # Prepare email data for thread pool | ||||||
|  |     email_data_list = [(email, subject, body, newsletter_id) for email in email_list] | ||||||
|  |      | ||||||
|  |     with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_EMAIL_WORKERS) as executor: | ||||||
|  |         future_to_email = { | ||||||
|  |             executor.submit(send_single_email, email_data): email_data[0]  | ||||||
|  |             for email_data in email_data_list | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         for future in concurrent.futures.as_completed(future_to_email): | ||||||
|  |             success, email, error = future.result() | ||||||
|  |             if success: | ||||||
|  |                 success_count += 1 | ||||||
|  |             else: | ||||||
|  |                 failure_count += 1 | ||||||
|  |                 failed_emails.append({'email': email, 'error': error}) | ||||||
|  |      | ||||||
|  |     return success_count, failure_count, failed_emails | ||||||
| 
 | 
 | ||||||
| @app.route("/") | @app.route("/") | ||||||
| @login_required | @login_required | ||||||
| def index(): | def index(): | ||||||
|     """Displays all subscriber emails""" |     """Dashboard with subscriber statistics and recent activity.""" | ||||||
|     emails = get_all_emails() |     page = request.args.get('page', 1, type=int) | ||||||
|     return render_template("admin_index.html", emails=emails) |     search = request.args.get('search', '') | ||||||
|  |     per_page = 25 | ||||||
|  |      | ||||||
|  |     stats = get_subscriber_stats() | ||||||
|  |     subscribers_data = get_all_emails(page=page, per_page=per_page, search=search) | ||||||
|  |     recent_newsletters = get_recent_newsletters(limit=5) | ||||||
|  |      | ||||||
|  |     return render_template( | ||||||
|  |         "admin_index.html",  | ||||||
|  |         stats=stats, | ||||||
|  |         subscribers=subscribers_data['subscribers'], | ||||||
|  |         pagination={ | ||||||
|  |             'page': subscribers_data['page'], | ||||||
|  |             'per_page': subscribers_data['per_page'], | ||||||
|  |             'total_pages': subscribers_data['total_pages'], | ||||||
|  |             'total_count': subscribers_data['total_count'] | ||||||
|  |         }, | ||||||
|  |         search=search, | ||||||
|  |         recent_newsletters=recent_newsletters | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
| @app.route("/send_update", methods=["GET", "POST"]) | @app.route("/subscribers") | ||||||
| @login_required | @login_required | ||||||
| def send_update(): | def subscribers(): | ||||||
|     """Display a form to send an update email; process submission on POST.""" |     """Detailed subscriber management page.""" | ||||||
|     if request.method == "POST": |     page = request.args.get('page', 1, type=int) | ||||||
|         subject = request.form["subject"] |     search = request.args.get('search', '') | ||||||
|         body = request.form["body"] |     per_page = 50 | ||||||
|         result_message = process_send_update_email(subject, body) |      | ||||||
|         flash(result_message) |     subscribers_data = get_all_emails(page=page, per_page=per_page, search=search) | ||||||
|         return redirect(url_for("send_update")) |      | ||||||
|     return render_template("send_update.html") |     return render_template( | ||||||
|  |         "subscribers.html", | ||||||
|  |         subscribers=subscribers_data['subscribers'], | ||||||
|  |         pagination={ | ||||||
|  |             'page': subscribers_data['page'], | ||||||
|  |             'per_page': subscribers_data['per_page'], | ||||||
|  |             'total_pages': subscribers_data['total_pages'], | ||||||
|  |             'total_count': subscribers_data['total_count'] | ||||||
|  |         }, | ||||||
|  |         search=search | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|  | @app.route("/add_subscriber", methods=["POST"]) | ||||||
|  | @login_required | ||||||
|  | def add_subscriber(): | ||||||
|  |     """Add a new subscriber via AJAX.""" | ||||||
|  |     try: | ||||||
|  |         data = request.get_json() | ||||||
|  |         email = data.get('email', '').strip().lower() | ||||||
|  |          | ||||||
|  |         if not email or not validate_email(email): | ||||||
|  |             return jsonify({"success": False, "message": "Invalid email format"}), 400 | ||||||
|  |          | ||||||
|  |         success = add_email(email, source='admin_manual') | ||||||
|  |          | ||||||
|  |         if success: | ||||||
|  |             return jsonify({"success": True, "message": f"Successfully added {email}"}) | ||||||
|  |         else: | ||||||
|  |             return jsonify({"success": False, "message": "Email already exists or failed to add"}), 400 | ||||||
|  |              | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Error adding subscriber: {e}") | ||||||
|  |         return jsonify({"success": False, "message": "Server error occurred"}), 500 | ||||||
|  | 
 | ||||||
|  | @app.route("/remove_subscriber", methods=["POST"]) | ||||||
|  | @login_required | ||||||
|  | def remove_subscriber(): | ||||||
|  |     """Remove/unsubscribe a subscriber via AJAX.""" | ||||||
|  |     try: | ||||||
|  |         data = request.get_json() | ||||||
|  |         email = data.get('email', '').strip().lower() | ||||||
|  |          | ||||||
|  |         if not email: | ||||||
|  |             return jsonify({"success": False, "message": "Email is required"}), 400 | ||||||
|  |          | ||||||
|  |         success = remove_email(email) | ||||||
|  |          | ||||||
|  |         if success: | ||||||
|  |             return jsonify({"success": True, "message": f"Successfully unsubscribed {email}"}) | ||||||
|  |         else: | ||||||
|  |             return jsonify({"success": False, "message": "Email not found"}), 404 | ||||||
|  |              | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Error removing subscriber: {e}") | ||||||
|  |         return jsonify({"success": False, "message": "Server error occurred"}), 500 | ||||||
|  | 
 | ||||||
|  | @app.route("/send_newsletter", methods=["GET", "POST"]) | ||||||
|  | @login_required | ||||||
|  | def send_newsletter(): | ||||||
|  |     """Enhanced newsletter sending with preview and batch processing.""" | ||||||
|  |     if request.method == "POST": | ||||||
|  |         action = request.form.get('action', 'send') | ||||||
|  |         subject = request.form.get('subject', '').strip() | ||||||
|  |         body = request.form.get('body', '').strip() | ||||||
|  |          | ||||||
|  |         if not subject or not body: | ||||||
|  |             flash("Subject and body are required", "error") | ||||||
|  |             return redirect(url_for('send_newsletter')) | ||||||
|  |          | ||||||
|  |         if action == 'preview': | ||||||
|  |             # Return preview | ||||||
|  |             preview_html = f""" | ||||||
|  |             <div class="email-preview"> | ||||||
|  |                 <h3>Subject: {subject}</h3> | ||||||
|  |                 <div class="email-body">{body}</div> | ||||||
|  |                 <hr> | ||||||
|  |                 <p><small>Unsubscribe link will be automatically added to all emails</small></p> | ||||||
|  |             </div> | ||||||
|  |             """ | ||||||
|  |             return render_template("send_newsletter.html", preview=preview_html, subject=subject, body=body) | ||||||
|  |          | ||||||
|  |         elif action == 'send': | ||||||
|  |             # Get all active subscribers (backwards compatible) | ||||||
|  |             try: | ||||||
|  |                 with get_db_connection() as conn: | ||||||
|  |                     cursor = conn.cursor() | ||||||
|  |                      | ||||||
|  |                     # Check if status column exists for backwards compatibility | ||||||
|  |                     cursor.execute( | ||||||
|  |                         """ | ||||||
|  |                         SELECT EXISTS ( | ||||||
|  |                             SELECT FROM information_schema.columns  | ||||||
|  |                             WHERE table_name = 'subscribers' AND column_name = 'status' | ||||||
|  |                         ) | ||||||
|  |                         """ | ||||||
|  |                     ) | ||||||
|  |                     has_status = cursor.fetchone()[0] | ||||||
|  |                      | ||||||
|  |                     if has_status: | ||||||
|  |                         cursor.execute("SELECT email FROM subscribers WHERE status = 'active'") | ||||||
|  |                     else: | ||||||
|  |                         cursor.execute("SELECT email FROM subscribers") | ||||||
|  |                      | ||||||
|  |                     email_list = [row[0] for row in cursor.fetchall()] | ||||||
|  |                      | ||||||
|  |             except Exception as e: | ||||||
|  |                 logger.error(f"Error fetching subscriber emails: {e}") | ||||||
|  |                 flash("Error retrieving subscriber list", "error") | ||||||
|  |                 return redirect(url_for('send_newsletter')) | ||||||
|  |              | ||||||
|  |             if not email_list: | ||||||
|  |                 flash("No active subscribers found", "warning") | ||||||
|  |                 return redirect(url_for('send_newsletter')) | ||||||
|  |              | ||||||
|  |             # Save newsletter to database | ||||||
|  |             newsletter_id = save_newsletter(subject, body, session['username'], len(email_list)) | ||||||
|  |              | ||||||
|  |             # Send emails in batches | ||||||
|  |             try: | ||||||
|  |                 success_count, failure_count, failed_emails = send_newsletter_batch( | ||||||
|  |                     subject, body, email_list, newsletter_id | ||||||
|  |                 ) | ||||||
|  |                  | ||||||
|  |                 # Update newsletter statistics | ||||||
|  |                 if newsletter_id: | ||||||
|  |                     update_newsletter_stats(newsletter_id, success_count, failure_count) | ||||||
|  |                  | ||||||
|  |                 # Flash results | ||||||
|  |                 if success_count > 0: | ||||||
|  |                     flash(f"Newsletter sent successfully to {success_count} subscribers!", "success") | ||||||
|  |                  | ||||||
|  |                 if failure_count > 0: | ||||||
|  |                     flash(f"Failed to send to {failure_count} subscribers", "error") | ||||||
|  |                     for failed in failed_emails[:5]:  # Show first 5 failures | ||||||
|  |                         flash(f"Failed: {failed['email']} - {failed['error'][:100]}", "warning") | ||||||
|  |                  | ||||||
|  |             except Exception as e: | ||||||
|  |                 logger.error(f"Error sending newsletter: {e}") | ||||||
|  |                 flash(f"Error sending newsletter: {str(e)}", "error") | ||||||
|  |              | ||||||
|  |             return redirect(url_for('send_newsletter')) | ||||||
|  |      | ||||||
|  |     return render_template("send_newsletter.html") | ||||||
|  | 
 | ||||||
|  | @app.route("/newsletter_history") | ||||||
|  | @login_required | ||||||
|  | def newsletter_history(): | ||||||
|  |     """View newsletter sending history.""" | ||||||
|  |     newsletters = get_recent_newsletters(limit=50) | ||||||
|  |     return render_template("newsletter_history.html", newsletters=newsletters) | ||||||
| 
 | 
 | ||||||
| @app.route("/login", methods=["GET", "POST"]) | @app.route("/login", methods=["GET", "POST"]) | ||||||
| def login(): | def login(): | ||||||
|     if request.method == "POST": |     if request.method == "POST": | ||||||
|         username = request.form.get("username") |         username = request.form.get("username", "").strip() | ||||||
|         password = request.form.get("password") |         password = request.form.get("password", "") | ||||||
|  |          | ||||||
|  |         if not username or not password: | ||||||
|  |             flash("Username and password are required", "error") | ||||||
|  |             return redirect(url_for("login")) | ||||||
|  |          | ||||||
|         admin = get_admin(username) |         admin = get_admin(username) | ||||||
|         if admin and check_password_hash(admin[1], password): |         if admin and len(admin) >= 3 and check_password_hash(admin[2], password): | ||||||
|  |             if len(admin) >= 4 and not admin[3]:  # Check is_active | ||||||
|  |                 flash("Account is disabled", "error") | ||||||
|  |                 return redirect(url_for("login")) | ||||||
|  |                  | ||||||
|             session["username"] = username |             session["username"] = username | ||||||
|             flash("Logged in successfully", "success") |             session["admin_id"] = admin[0] | ||||||
|  |             flash("Logged in successfully!", "success") | ||||||
|  |              | ||||||
|  |             # Redirect to intended page or dashboard | ||||||
|  |             next_page = request.args.get('next') | ||||||
|  |             if next_page: | ||||||
|  |                 return redirect(next_page) | ||||||
|             return redirect(url_for("index")) |             return redirect(url_for("index")) | ||||||
|         else: |         else: | ||||||
|             flash("Invalid username or password", "danger") |             flash("Invalid username or password", "error") | ||||||
|             return redirect(url_for("login")) |             return redirect(url_for("login")) | ||||||
|  |      | ||||||
|     return render_template("login.html") |     return render_template("login.html") | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| @app.route("/logout") | @app.route("/logout") | ||||||
| def logout(): | def logout(): | ||||||
|     session.pop("username", None) |     session.clear() | ||||||
|     flash("Logged out successfully", "success") |     flash("You have been logged out successfully", "info") | ||||||
|     return redirect(url_for("login")) |     return redirect(url_for("login")) | ||||||
| 
 | 
 | ||||||
|  | # Public unsubscribe endpoint | ||||||
|  | @app.route("/unsubscribe") | ||||||
|  | def unsubscribe(): | ||||||
|  |     """Public unsubscribe endpoint.""" | ||||||
|  |     email = request.args.get('email', '').strip().lower() | ||||||
|  |      | ||||||
|  |     if not email or not validate_email(email): | ||||||
|  |         return render_template("unsubscribe.html", error="Invalid email address") | ||||||
|  |      | ||||||
|  |     success = remove_email(email) | ||||||
|  |      | ||||||
|  |     if success: | ||||||
|  |         return render_template("unsubscribe.html", success=True, email=email) | ||||||
|  |     else: | ||||||
|  |         return render_template("unsubscribe.html", error="Email not found or already unsubscribed") | ||||||
|  | 
 | ||||||
|  | # API endpoints for AJAX requests | ||||||
|  | @app.route("/api/stats") | ||||||
|  | @login_required | ||||||
|  | def api_stats(): | ||||||
|  |     """API endpoint for dashboard statistics.""" | ||||||
|  |     stats = get_subscriber_stats() | ||||||
|  |     return jsonify(stats) | ||||||
|  | 
 | ||||||
|  | # Error handlers | ||||||
|  | @app.errorhandler(404) | ||||||
|  | def not_found(error): | ||||||
|  |     return render_template("error.html", error="Page not found", code=404), 404 | ||||||
|  | 
 | ||||||
|  | @app.errorhandler(500) | ||||||
|  | def internal_error(error): | ||||||
|  |     logger.error(f"Internal error: {error}") | ||||||
|  |     return render_template("error.html", error="Internal server error", code=500), 500 | ||||||
|  | 
 | ||||||
|  | # Context processors | ||||||
|  | @app.context_processor | ||||||
|  | def inject_user(): | ||||||
|  |     return dict(current_user=session.get('username')) | ||||||
| 
 | 
 | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|     app.run(port=5001, debug=True) |     app.run(host="0.0.0.0", port=5001, debug=True) | ||||||
							
								
								
									
										575
									
								
								database.py
									
										
									
									
									
								
							
							
						
						
									
										575
									
								
								database.py
									
										
									
									
									
								
							|  | @ -1,9 +1,11 @@ | ||||||
| import os | import os | ||||||
| import logging | import logging | ||||||
| import psycopg2 | import psycopg2 | ||||||
| from psycopg2 import IntegrityError | from psycopg2 import IntegrityError, pool | ||||||
| from dotenv import load_dotenv | from dotenv import load_dotenv | ||||||
| from werkzeug.security import generate_password_hash | from werkzeug.security import generate_password_hash | ||||||
|  | from contextlib import contextmanager | ||||||
|  | from datetime import datetime, timezone | ||||||
| 
 | 
 | ||||||
| load_dotenv() | load_dotenv() | ||||||
| 
 | 
 | ||||||
|  | @ -13,191 +15,508 @@ logging.basicConfig( | ||||||
| ) | ) | ||||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||||
| 
 | 
 | ||||||
|  | # Connection pool for better performance | ||||||
|  | connection_pool = None | ||||||
| 
 | 
 | ||||||
| def get_connection(): | def init_connection_pool(): | ||||||
|     """Return a new connection to the PostgreSQL database.""" |     """Initialize the connection pool.""" | ||||||
|  |     global connection_pool | ||||||
|     try: |     try: | ||||||
|         conn = psycopg2.connect( |         connection_pool = psycopg2.pool.ThreadedConnectionPool( | ||||||
|  |             1, 20,  # min and max connections | ||||||
|             host=os.getenv("PG_HOST"), |             host=os.getenv("PG_HOST"), | ||||||
|             port=os.getenv("PG_PORT"), |             port=os.getenv("PG_PORT"), | ||||||
|             dbname=os.getenv("PG_DATABASE"), |             dbname=os.getenv("PG_DATABASE"), | ||||||
|             user=os.getenv("PG_USER"), |             user=os.getenv("PG_USER"), | ||||||
|             password=os.getenv("PG_PASSWORD"), |             password=os.getenv("PG_PASSWORD"), | ||||||
|             connect_timeout=10, |  | ||||||
|         ) |         ) | ||||||
|         return conn |         logger.info("Connection pool created successfully") | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         logger.error(f"Database connection error: {e}") |         logger.error(f"Connection pool creation error: {e}") | ||||||
|         raise |         raise | ||||||
| 
 | 
 | ||||||
|  | @contextmanager | ||||||
|  | def get_db_connection(): | ||||||
|  |     """Context manager for database connections.""" | ||||||
|  |     if connection_pool is None: | ||||||
|  |         init_connection_pool() | ||||||
|  |      | ||||||
|  |     conn = None | ||||||
|  |     try: | ||||||
|  |         conn = connection_pool.getconn() | ||||||
|  |         yield conn | ||||||
|  |     except Exception as e: | ||||||
|  |         if conn: | ||||||
|  |             conn.rollback() | ||||||
|  |         logger.error(f"Database operation error: {e}") | ||||||
|  |         raise | ||||||
|  |     finally: | ||||||
|  |         if conn: | ||||||
|  |             connection_pool.putconn(conn) | ||||||
|  | 
 | ||||||
|  | def check_column_exists(cursor, table_name, column_name): | ||||||
|  |     """Check if a column exists in a table.""" | ||||||
|  |     cursor.execute( | ||||||
|  |         """ | ||||||
|  |         SELECT EXISTS ( | ||||||
|  |             SELECT FROM information_schema.columns  | ||||||
|  |             WHERE table_name = %s AND column_name = %s | ||||||
|  |         ) | ||||||
|  |         """, | ||||||
|  |         (table_name, column_name) | ||||||
|  |     ) | ||||||
|  |     return cursor.fetchone()[0] | ||||||
|  | 
 | ||||||
|  | def migrate_database(conn): | ||||||
|  |     """Apply database migrations to upgrade existing schema.""" | ||||||
|  |     cursor = conn.cursor() | ||||||
|  |      | ||||||
|  |     # Migration 1: Add new columns to subscribers table | ||||||
|  |     if not check_column_exists(cursor, 'subscribers', 'subscribed_at'): | ||||||
|  |         logger.info("Adding subscribed_at column to subscribers table") | ||||||
|  |         cursor.execute( | ||||||
|  |             "ALTER TABLE subscribers ADD COLUMN subscribed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP" | ||||||
|  |         ) | ||||||
|  |      | ||||||
|  |     if not check_column_exists(cursor, 'subscribers', 'status'): | ||||||
|  |         logger.info("Adding status column to subscribers table") | ||||||
|  |         cursor.execute( | ||||||
|  |             "ALTER TABLE subscribers ADD COLUMN status TEXT DEFAULT 'active'" | ||||||
|  |         ) | ||||||
|  |         # Add check constraint | ||||||
|  |         cursor.execute( | ||||||
|  |             "ALTER TABLE subscribers ADD CONSTRAINT subscribers_status_check CHECK (status IN ('active', 'unsubscribed'))" | ||||||
|  |         ) | ||||||
|  |         # Update existing rows | ||||||
|  |         cursor.execute("UPDATE subscribers SET status = 'active' WHERE status IS NULL") | ||||||
|  |      | ||||||
|  |     if not check_column_exists(cursor, 'subscribers', 'source'): | ||||||
|  |         logger.info("Adding source column to subscribers table") | ||||||
|  |         cursor.execute( | ||||||
|  |             "ALTER TABLE subscribers ADD COLUMN source TEXT DEFAULT 'manual'" | ||||||
|  |         ) | ||||||
|  |      | ||||||
|  |     # Migration 2: Add new columns to admin_users table | ||||||
|  |     if not check_column_exists(cursor, 'admin_users', 'created_at'): | ||||||
|  |         logger.info("Adding created_at column to admin_users table") | ||||||
|  |         cursor.execute( | ||||||
|  |             "ALTER TABLE admin_users ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP" | ||||||
|  |         ) | ||||||
|  |      | ||||||
|  |     if not check_column_exists(cursor, 'admin_users', 'last_login'): | ||||||
|  |         logger.info("Adding last_login column to admin_users table") | ||||||
|  |         cursor.execute( | ||||||
|  |             "ALTER TABLE admin_users ADD COLUMN last_login TIMESTAMP WITH TIME ZONE" | ||||||
|  |         ) | ||||||
|  |      | ||||||
|  |     if not check_column_exists(cursor, 'admin_users', 'is_active'): | ||||||
|  |         logger.info("Adding is_active column to admin_users table") | ||||||
|  |         cursor.execute( | ||||||
|  |             "ALTER TABLE admin_users ADD COLUMN is_active BOOLEAN DEFAULT TRUE" | ||||||
|  |         ) | ||||||
|  |      | ||||||
|  |     # Migration 3: Add new columns to newsletters table | ||||||
|  |     if not check_column_exists(cursor, 'newsletters', 'sent_by'): | ||||||
|  |         logger.info("Adding sent_by column to newsletters table") | ||||||
|  |         cursor.execute( | ||||||
|  |             "ALTER TABLE newsletters ADD COLUMN sent_by TEXT" | ||||||
|  |         ) | ||||||
|  |      | ||||||
|  |     if not check_column_exists(cursor, 'newsletters', 'recipient_count'): | ||||||
|  |         logger.info("Adding recipient_count column to newsletters table") | ||||||
|  |         cursor.execute( | ||||||
|  |             "ALTER TABLE newsletters ADD COLUMN recipient_count INTEGER DEFAULT 0" | ||||||
|  |         ) | ||||||
|  |      | ||||||
|  |     if not check_column_exists(cursor, 'newsletters', 'success_count'): | ||||||
|  |         logger.info("Adding success_count column to newsletters table") | ||||||
|  |         cursor.execute( | ||||||
|  |             "ALTER TABLE newsletters ADD COLUMN success_count INTEGER DEFAULT 0" | ||||||
|  |         ) | ||||||
|  |      | ||||||
|  |     if not check_column_exists(cursor, 'newsletters', 'failure_count'): | ||||||
|  |         logger.info("Adding failure_count column to newsletters table") | ||||||
|  |         cursor.execute( | ||||||
|  |             "ALTER TABLE newsletters ADD COLUMN failure_count INTEGER DEFAULT 0" | ||||||
|  |         ) | ||||||
|  |      | ||||||
|  |     conn.commit() | ||||||
|  |     logger.info("Database migrations completed successfully") | ||||||
| 
 | 
 | ||||||
| def init_db(): | def init_db(): | ||||||
|     """Initialize the database tables.""" |     """Initialize the database tables with improved schema.""" | ||||||
|     conn = None |  | ||||||
|     try: |     try: | ||||||
|         conn = get_connection() |         with get_db_connection() as conn: | ||||||
|         cursor = conn.cursor() |             cursor = conn.cursor() | ||||||
| 
 | 
 | ||||||
|         # Create subscribers table (if not exists) |             # Create basic subscribers table (backwards compatible) | ||||||
|         cursor.execute( |             cursor.execute( | ||||||
|  |                 """ | ||||||
|  |                 CREATE TABLE IF NOT EXISTS subscribers ( | ||||||
|  |                     id SERIAL PRIMARY KEY, | ||||||
|  |                     email TEXT UNIQUE NOT NULL | ||||||
|  |                 ) | ||||||
|             """ |             """ | ||||||
|             CREATE TABLE IF NOT EXISTS subscribers ( |  | ||||||
|                 id SERIAL PRIMARY KEY, |  | ||||||
|                 email TEXT UNIQUE NOT NULL |  | ||||||
|             ) |             ) | ||||||
|         """ |  | ||||||
|         ) |  | ||||||
| 
 | 
 | ||||||
|         # Create admin_users table (if not exists) |             # Create basic admin_users table (backwards compatible) | ||||||
|         cursor.execute( |             cursor.execute( | ||||||
|  |                 """ | ||||||
|  |                 CREATE TABLE IF NOT EXISTS admin_users ( | ||||||
|  |                     id SERIAL PRIMARY KEY, | ||||||
|  |                     username TEXT UNIQUE NOT NULL, | ||||||
|  |                     password TEXT NOT NULL | ||||||
|  |                 ) | ||||||
|             """ |             """ | ||||||
|             CREATE TABLE IF NOT EXISTS admin_users ( |  | ||||||
|                 id SERIAL PRIMARY KEY, |  | ||||||
|                 username TEXT UNIQUE NOT NULL, |  | ||||||
|                 password TEXT NOT NULL |  | ||||||
|             ) |             ) | ||||||
|         """ |  | ||||||
|         ) |  | ||||||
| 
 | 
 | ||||||
|         # Newsletter storage |             # Create basic newsletters table (backwards compatible) | ||||||
|         cursor.execute( |             cursor.execute( | ||||||
|  |                 """ | ||||||
|  |                 CREATE TABLE IF NOT EXISTS newsletters ( | ||||||
|  |                     id SERIAL PRIMARY KEY, | ||||||
|  |                     subject TEXT NOT NULL, | ||||||
|  |                     body TEXT NOT NULL, | ||||||
|  |                     sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP | ||||||
|  |                 ) | ||||||
|             """ |             """ | ||||||
|         CREATE TABLE IF NOT EXISTS newsletters ( |             ) | ||||||
|             id SERIAL PRIMARY KEY, |  | ||||||
|             subject TEXT NOT NULL, |  | ||||||
|             body TEXT NOT NULL, |  | ||||||
|             sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP |  | ||||||
|         ) |  | ||||||
|         """ |  | ||||||
|         ) |  | ||||||
| 
 | 
 | ||||||
|         conn.commit() |             # Email delivery tracking (new table) | ||||||
|         logger.info("Database initialized successfully.") |             cursor.execute( | ||||||
|  |                 """ | ||||||
|  |                 CREATE TABLE IF NOT EXISTS email_deliveries ( | ||||||
|  |                     id SERIAL PRIMARY KEY, | ||||||
|  |                     newsletter_id INTEGER REFERENCES newsletters(id), | ||||||
|  |                     email TEXT NOT NULL, | ||||||
|  |                     status TEXT CHECK (status IN ('sent', 'failed', 'bounced')), | ||||||
|  |                     sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |                     error_message TEXT | ||||||
|  |                 ) | ||||||
|  |             """ | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             conn.commit() | ||||||
|  |             logger.info("Basic database tables created successfully") | ||||||
|  |              | ||||||
|  |             # Apply migrations to upgrade schema | ||||||
|  |             migrate_database(conn) | ||||||
|  |              | ||||||
|  |             # Add indexes after migrations are complete | ||||||
|  |             try: | ||||||
|  |                 cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email)") | ||||||
|  |                 cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscribers_status ON subscribers(status)") | ||||||
|  |                 cursor.execute("CREATE INDEX IF NOT EXISTS idx_newsletters_sent_at ON newsletters(sent_at)") | ||||||
|  |                 conn.commit() | ||||||
|  |                 logger.info("Database indexes created successfully") | ||||||
|  |             except Exception as e: | ||||||
|  |                 logger.warning(f"Some indexes may not have been created: {e}") | ||||||
|  |                 # Continue anyway as this is not critical | ||||||
|  |              | ||||||
|  |             logger.info("Database initialization completed successfully") | ||||||
|  |              | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         logger.error(f"Database initialization error: {e}") |         logger.error(f"Database initialization error: {e}") | ||||||
|         if conn: |  | ||||||
|             conn.rollback()  # Rollback if there's an error |  | ||||||
| 
 |  | ||||||
|         raise |         raise | ||||||
|     finally: |  | ||||||
|         if conn: |  | ||||||
|             cursor.close() |  | ||||||
|             conn.close() |  | ||||||
| 
 | 
 | ||||||
| 
 | def get_subscriber_stats(): | ||||||
| def get_all_emails(): |     """Get comprehensive subscriber statistics.""" | ||||||
|     """Return a list of all subscriber emails.""" |  | ||||||
|     try: |     try: | ||||||
|         conn = get_connection() |         with get_db_connection() as conn: | ||||||
|         cursor = conn.cursor() |             cursor = conn.cursor() | ||||||
|         cursor.execute("SELECT email FROM subscribers") |              | ||||||
|         results = cursor.fetchall() |             # Check if status column exists | ||||||
|         emails = [row[0] for row in results] |             has_status = check_column_exists(cursor, 'subscribers', 'status') | ||||||
|         logger.debug(f"Retrieved emails: {emails}") |              | ||||||
|         return emails |             if has_status: | ||||||
|  |                 # Get total subscribers with status filtering | ||||||
|  |                 cursor.execute("SELECT COUNT(*) FROM subscribers WHERE status = 'active'") | ||||||
|  |                 total_active = cursor.fetchone()[0] | ||||||
|  |                  | ||||||
|  |                 cursor.execute("SELECT COUNT(*) FROM subscribers WHERE status = 'unsubscribed'") | ||||||
|  |                 total_unsubscribed = cursor.fetchone()[0] | ||||||
|  |                  | ||||||
|  |                 # Get recent signups (last 30 days) - check if subscribed_at exists | ||||||
|  |                 has_subscribed_at = check_column_exists(cursor, 'subscribers', 'subscribed_at') | ||||||
|  |                 if has_subscribed_at: | ||||||
|  |                     cursor.execute(""" | ||||||
|  |                         SELECT COUNT(*) FROM subscribers  | ||||||
|  |                         WHERE subscribed_at >= NOW() - INTERVAL '30 days' AND status = 'active' | ||||||
|  |                     """) | ||||||
|  |                     recent_signups = cursor.fetchone()[0] | ||||||
|  |                 else: | ||||||
|  |                     recent_signups = 0 | ||||||
|  |             else: | ||||||
|  |                 # Fallback for old schema | ||||||
|  |                 cursor.execute("SELECT COUNT(*) FROM subscribers") | ||||||
|  |                 total_active = cursor.fetchone()[0] | ||||||
|  |                 total_unsubscribed = 0 | ||||||
|  |                 recent_signups = 0 | ||||||
|  |              | ||||||
|  |             # Get newsletters sent | ||||||
|  |             cursor.execute("SELECT COUNT(*) FROM newsletters") | ||||||
|  |             newsletters_sent = cursor.fetchone()[0] | ||||||
|  |              | ||||||
|  |             return { | ||||||
|  |                 'total_active': total_active, | ||||||
|  |                 'total_unsubscribed': total_unsubscribed, | ||||||
|  |                 'recent_signups': recent_signups, | ||||||
|  |                 'newsletters_sent': newsletters_sent | ||||||
|  |             } | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Error retrieving subscriber stats: {e}") | ||||||
|  |         return {'total_active': 0, 'total_unsubscribed': 0, 'recent_signups': 0, 'newsletters_sent': 0} | ||||||
|  | 
 | ||||||
|  | def get_all_emails(page=1, per_page=50, search=''): | ||||||
|  |     """Return paginated list of subscriber emails with search functionality.""" | ||||||
|  |     try: | ||||||
|  |         with get_db_connection() as conn: | ||||||
|  |             cursor = conn.cursor() | ||||||
|  |              | ||||||
|  |             # Check which columns exist | ||||||
|  |             has_status = check_column_exists(cursor, 'subscribers', 'status') | ||||||
|  |             has_subscribed_at = check_column_exists(cursor, 'subscribers', 'subscribed_at') | ||||||
|  |             has_source = check_column_exists(cursor, 'subscribers', 'source') | ||||||
|  |              | ||||||
|  |             # Calculate offset | ||||||
|  |             offset = (page - 1) * per_page | ||||||
|  |              | ||||||
|  |             # Build base query based on available columns | ||||||
|  |             if has_status: | ||||||
|  |                 base_query = "FROM subscribers WHERE status = 'active'" | ||||||
|  |             else: | ||||||
|  |                 base_query = "FROM subscribers WHERE 1=1" | ||||||
|  |              | ||||||
|  |             params = [] | ||||||
|  |              | ||||||
|  |             if search: | ||||||
|  |                 base_query += " AND email ILIKE %s" | ||||||
|  |                 params.append(f"%{search}%") | ||||||
|  |              | ||||||
|  |             # Get total count | ||||||
|  |             cursor.execute(f"SELECT COUNT(*) {base_query}", params) | ||||||
|  |             total_count = cursor.fetchone()[0] | ||||||
|  |              | ||||||
|  |             # Build select query based on available columns | ||||||
|  |             select_fields = ["id", "email"] | ||||||
|  |             if has_subscribed_at: | ||||||
|  |                 select_fields.append("subscribed_at") | ||||||
|  |             if has_source: | ||||||
|  |                 select_fields.append("source") | ||||||
|  |              | ||||||
|  |             # Get paginated results | ||||||
|  |             query = f""" | ||||||
|  |                 SELECT {', '.join(select_fields)} | ||||||
|  |                 {base_query}  | ||||||
|  |                 ORDER BY {"subscribed_at DESC" if has_subscribed_at else "id DESC"} | ||||||
|  |                 LIMIT %s OFFSET %s | ||||||
|  |             """ | ||||||
|  |             params.extend([per_page, offset]) | ||||||
|  |             cursor.execute(query, params) | ||||||
|  |              | ||||||
|  |             results = cursor.fetchall() | ||||||
|  |             subscribers = [] | ||||||
|  |              | ||||||
|  |             for row in results: | ||||||
|  |                 subscriber = { | ||||||
|  |                     'id': row[0], | ||||||
|  |                     'email': row[1], | ||||||
|  |                     'subscribed_at': row[2] if has_subscribed_at and len(row) > 2 else None, | ||||||
|  |                     'source': row[3] if has_source and len(row) > 3 else 'manual' | ||||||
|  |                 } | ||||||
|  |                 subscribers.append(subscriber) | ||||||
|  |              | ||||||
|  |             return { | ||||||
|  |                 'subscribers': subscribers, | ||||||
|  |                 'total_count': total_count, | ||||||
|  |                 'page': page, | ||||||
|  |                 'per_page': per_page, | ||||||
|  |                 'total_pages': (total_count + per_page - 1) // per_page | ||||||
|  |             } | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         logger.error(f"Error retrieving emails: {e}") |         logger.error(f"Error retrieving emails: {e}") | ||||||
|         return [] |         return {'subscribers': [], 'total_count': 0, 'page': 1, 'per_page': per_page, 'total_pages': 0} | ||||||
|     finally: |  | ||||||
|         if conn: |  | ||||||
|             cursor.close() |  | ||||||
|             conn.close() |  | ||||||
| 
 | 
 | ||||||
| 
 | def add_email(email, source='manual'): | ||||||
| def add_email(email): |  | ||||||
|     """Insert an email into the subscribers table.""" |     """Insert an email into the subscribers table.""" | ||||||
|     conn = None |  | ||||||
|     try: |     try: | ||||||
|         conn = get_connection() |         with get_db_connection() as conn: | ||||||
|         cursor = conn.cursor() |             cursor = conn.cursor() | ||||||
|         cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) |              | ||||||
|         conn.commit() |             # Check if source column exists | ||||||
|         logger.info(f"Email {email} added successfully.") |             has_source = check_column_exists(cursor, 'subscribers', 'source') | ||||||
|         return True |              | ||||||
|  |             if has_source: | ||||||
|  |                 cursor.execute( | ||||||
|  |                     "INSERT INTO subscribers (email, source) VALUES (%s, %s)",  | ||||||
|  |                     (email.lower().strip(), source) | ||||||
|  |                 ) | ||||||
|  |             else: | ||||||
|  |                 cursor.execute( | ||||||
|  |                     "INSERT INTO subscribers (email) VALUES (%s)",  | ||||||
|  |                     (email.lower().strip(),) | ||||||
|  |                 ) | ||||||
|  |                  | ||||||
|  |             conn.commit() | ||||||
|  |             logger.info(f"Email {email} added successfully.") | ||||||
|  |             return True | ||||||
|     except IntegrityError: |     except IntegrityError: | ||||||
|         logger.warning(f"Attempted to add duplicate email: {email}") |         logger.warning(f"Attempted to add duplicate email: {email}") | ||||||
|         return False |         return False | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         logger.error(f"Error adding email {email}: {e}") |         logger.error(f"Error adding email {email}: {e}") | ||||||
|         return False |         return False | ||||||
|     finally: |  | ||||||
|         if conn: |  | ||||||
|             cursor.close() |  | ||||||
|             conn.close() |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| def remove_email(email): | def remove_email(email): | ||||||
|     """Remove an email from the subscribers table.""" |     """Mark email as unsubscribed or delete if status column doesn't exist.""" | ||||||
|     conn = None |  | ||||||
|     try: |     try: | ||||||
|         conn = get_connection() |         with get_db_connection() as conn: | ||||||
|         cursor = conn.cursor() |             cursor = conn.cursor() | ||||||
|         cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) |              | ||||||
|         rowcount = cursor.rowcount |             # Check if status column exists | ||||||
|         conn.commit() |             has_status = check_column_exists(cursor, 'subscribers', 'status') | ||||||
|         logger.info(f"Email {email} removed successfully.") |              | ||||||
|         return rowcount > 0 |             if has_status: | ||||||
|  |                 # Mark as unsubscribed | ||||||
|  |                 cursor.execute( | ||||||
|  |                     "UPDATE subscribers SET status = 'unsubscribed' WHERE email = %s",  | ||||||
|  |                     (email,) | ||||||
|  |                 ) | ||||||
|  |             else: | ||||||
|  |                 # Delete the record (old behavior) | ||||||
|  |                 cursor.execute( | ||||||
|  |                     "DELETE FROM subscribers WHERE email = %s",  | ||||||
|  |                     (email,) | ||||||
|  |                 ) | ||||||
|  |                  | ||||||
|  |             rowcount = cursor.rowcount | ||||||
|  |             conn.commit() | ||||||
|  |             logger.info(f"Email {email} {'unsubscribed' if has_status else 'removed'} successfully.") | ||||||
|  |             return rowcount > 0 | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         logger.error(f"Error removing email {email}: {e}") |         logger.error(f"Error {'unsubscribing' if has_status else 'removing'} email {email}: {e}") | ||||||
|         return False |         return False | ||||||
|     finally: |  | ||||||
|         if conn: |  | ||||||
|             cursor.close() |  | ||||||
|             conn.close() |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| def get_admin(username): | def get_admin(username): | ||||||
|     """Retrieve admin credentials for a given username. |     """Retrieve admin credentials and update last login.""" | ||||||
|     Returns a tuple (username, password_hash) if found, otherwise None. |  | ||||||
|     """ |  | ||||||
|     conn = None |  | ||||||
|     try: |     try: | ||||||
|         conn = get_connection() |         with get_db_connection() as conn: | ||||||
|         cursor = conn.cursor() |             cursor = conn.cursor() | ||||||
|         cursor.execute( |             cursor.execute( | ||||||
|             "SELECT username, password FROM admin_users WHERE username = %s", |                 """SELECT id, username, password, is_active  | ||||||
|             (username,), |                    FROM admin_users  | ||||||
|         ) |                    WHERE username = %s AND is_active = TRUE""", | ||||||
|         result = cursor.fetchone() |                 (username,), | ||||||
|         return result  # (username, password_hash) |             ) | ||||||
|  |             result = cursor.fetchone() | ||||||
|  |              | ||||||
|  |             if result: | ||||||
|  |                 # Update last login | ||||||
|  |                 cursor.execute( | ||||||
|  |                     "UPDATE admin_users SET last_login = %s WHERE id = %s", | ||||||
|  |                     (datetime.now(timezone.utc), result[0]) | ||||||
|  |                 ) | ||||||
|  |                 conn.commit() | ||||||
|  |                  | ||||||
|  |             return result | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         logger.error(f"Error retrieving admin: {e}") |         logger.error(f"Error retrieving admin: {e}") | ||||||
|         return None |         return None | ||||||
|     finally: |  | ||||||
|         if conn: |  | ||||||
|             cursor.close() |  | ||||||
|             conn.close() |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| def create_default_admin(): | def create_default_admin(): | ||||||
|     """Create a default admin user if one doesn't already exist.""" |     """Create a default admin user if one doesn't already exist.""" | ||||||
|     default_username = os.getenv("ADMIN_USERNAME", "admin") |     default_username = os.getenv("ADMIN_USERNAME", "admin") | ||||||
|     default_password = os.getenv("ADMIN_PASSWORD", "changeme") |     default_password = os.getenv("ADMIN_PASSWORD", "changeme") | ||||||
|     hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256") |     hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256") | ||||||
|     conn = None |      | ||||||
|     try: |     try: | ||||||
|         conn = get_connection() |         with get_db_connection() as conn: | ||||||
|         cursor = conn.cursor() |             cursor = conn.cursor() | ||||||
| 
 | 
 | ||||||
|         # Check if the admin already exists |             # Check if any admin exists | ||||||
|         cursor.execute( |             cursor.execute("SELECT COUNT(*) FROM admin_users WHERE is_active = TRUE") | ||||||
|             "SELECT id FROM admin_users WHERE username = %s", (default_username,) |             admin_count = cursor.fetchone()[0] | ||||||
|         ) |              | ||||||
|         if cursor.fetchone() is None: |             if admin_count == 0: | ||||||
|             cursor.execute( |                 cursor.execute( | ||||||
|                 "INSERT INTO admin_users (username, password) VALUES (%s, %s)", |                     "INSERT INTO admin_users (username, password) VALUES (%s, %s)", | ||||||
|                 (default_username, hashed_password), |                     (default_username, hashed_password), | ||||||
|             ) |                 ) | ||||||
|             conn.commit() |                 conn.commit() | ||||||
|             logger.info("Default admin created successfully") |                 logger.info("Default admin created successfully") | ||||||
|         else: |             else: | ||||||
|             logger.info("Default admin already exists") |                 logger.info("Admin users already exist") | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         logger.error(f"Error creating default admin: {e}") |         logger.error(f"Error creating default admin: {e}") | ||||||
|         if conn: |  | ||||||
|             conn.rollback() |  | ||||||
|     finally: |  | ||||||
|         if conn: |  | ||||||
|             cursor.close() |  | ||||||
|             conn.close() |  | ||||||
| 
 | 
 | ||||||
|  | def save_newsletter(subject, body, sent_by, recipient_count=0): | ||||||
|  |     """Save newsletter to database and return the ID.""" | ||||||
|  |     try: | ||||||
|  |         with get_db_connection() as conn: | ||||||
|  |             cursor = conn.cursor() | ||||||
|  |             cursor.execute( | ||||||
|  |                 """INSERT INTO newsletters (subject, body, sent_by, recipient_count)  | ||||||
|  |                    VALUES (%s, %s, %s, %s) RETURNING id""", | ||||||
|  |                 (subject, body, sent_by, recipient_count) | ||||||
|  |             ) | ||||||
|  |             newsletter_id = cursor.fetchone()[0] | ||||||
|  |             conn.commit() | ||||||
|  |             return newsletter_id | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Error saving newsletter: {e}") | ||||||
|  |         return None | ||||||
|  | 
 | ||||||
|  | def update_newsletter_stats(newsletter_id, success_count, failure_count): | ||||||
|  |     """Update newsletter delivery statistics.""" | ||||||
|  |     try: | ||||||
|  |         with get_db_connection() as conn: | ||||||
|  |             cursor = conn.cursor() | ||||||
|  |             cursor.execute( | ||||||
|  |                 """UPDATE newsletters  | ||||||
|  |                    SET success_count = %s, failure_count = %s  | ||||||
|  |                    WHERE id = %s""", | ||||||
|  |                 (success_count, failure_count, newsletter_id) | ||||||
|  |             ) | ||||||
|  |             conn.commit() | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Error updating newsletter stats: {e}") | ||||||
|  | 
 | ||||||
|  | def log_email_delivery(newsletter_id, email, status, error_message=None): | ||||||
|  |     """Log individual email delivery attempt.""" | ||||||
|  |     try: | ||||||
|  |         with get_db_connection() as conn: | ||||||
|  |             cursor = conn.cursor() | ||||||
|  |             cursor.execute( | ||||||
|  |                 """INSERT INTO email_deliveries (newsletter_id, email, status, error_message)  | ||||||
|  |                    VALUES (%s, %s, %s, %s)""", | ||||||
|  |                 (newsletter_id, email, status, error_message) | ||||||
|  |             ) | ||||||
|  |             conn.commit() | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Error logging email delivery: {e}") | ||||||
|  | 
 | ||||||
|  | def get_recent_newsletters(limit=10): | ||||||
|  |     """Get recent newsletters with statistics.""" | ||||||
|  |     try: | ||||||
|  |         with get_db_connection() as conn: | ||||||
|  |             cursor = conn.cursor() | ||||||
|  |             cursor.execute( | ||||||
|  |                 """SELECT id, subject, sent_at, sent_by, recipient_count, success_count, failure_count | ||||||
|  |                    FROM newsletters  | ||||||
|  |                    ORDER BY sent_at DESC  | ||||||
|  |                    LIMIT %s""", | ||||||
|  |                 (limit,) | ||||||
|  |             ) | ||||||
|  |             results = cursor.fetchall() | ||||||
|  |             return [{ | ||||||
|  |                 'id': row[0], | ||||||
|  |                 'subject': row[1], | ||||||
|  |                 'sent_at': row[2], | ||||||
|  |                 'sent_by': row[3], | ||||||
|  |                 'recipient_count': row[4], | ||||||
|  |                 'success_count': row[5], | ||||||
|  |                 'failure_count': row[6] | ||||||
|  |             } for row in results] | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Error retrieving recent newsletters: {e}") | ||||||
|  |         return [] | ||||||
|  | @ -3,42 +3,866 @@ | ||||||
| <head> | <head> | ||||||
|     <meta charset="UTF-8"> |     <meta charset="UTF-8"> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|     <title>Admin Center - Subscribers</title> |     <title>Newsletter Admin Dashboard</title> | ||||||
|     <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> |     <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> | ||||||
|  |     <style> | ||||||
|  |         * { | ||||||
|  |             margin: 0; | ||||||
|  |             padding: 0; | ||||||
|  |             box-sizing: border-box; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         :root { | ||||||
|  |             --primary: #667eea; | ||||||
|  |             --primary-dark: #5a67d8; | ||||||
|  |             --secondary: #764ba2; | ||||||
|  |             --success: #48bb78; | ||||||
|  |             --warning: #ed8936; | ||||||
|  |             --error: #f56565; | ||||||
|  |             --info: #4299e1; | ||||||
|  |             --dark: #2d3748; | ||||||
|  |             --light: #f7fafc; | ||||||
|  |             --gray-100: #f7fafc; | ||||||
|  |             --gray-200: #edf2f7; | ||||||
|  |             --gray-300: #e2e8f0; | ||||||
|  |             --gray-400: #cbd5e0; | ||||||
|  |             --gray-500: #a0aec0; | ||||||
|  |             --gray-600: #718096; | ||||||
|  |             --gray-700: #4a5568; | ||||||
|  |             --gray-800: #2d3748; | ||||||
|  |             --gray-900: #1a202c; | ||||||
|  |             --white: #ffffff; | ||||||
|  |             --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); | ||||||
|  |             --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); | ||||||
|  |             --border-radius: 8px; | ||||||
|  |             --border-radius-lg: 12px; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         body { | ||||||
|  |             font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | ||||||
|  |             background: linear-gradient(135deg, var(--gray-100) 0%, var(--gray-200) 100%); | ||||||
|  |             color: var(--gray-800); | ||||||
|  |             line-height: 1.6; | ||||||
|  |             min-height: 100vh; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .container { | ||||||
|  |             max-width: 1200px; | ||||||
|  |             margin: 0 auto; | ||||||
|  |             padding: 2rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .header { | ||||||
|  |             background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); | ||||||
|  |             color: white; | ||||||
|  |             padding: 2rem 0; | ||||||
|  |             margin-bottom: 2rem; | ||||||
|  |             border-radius: var(--border-radius-lg); | ||||||
|  |             box-shadow: var(--shadow-lg); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .header-content { | ||||||
|  |             max-width: 1200px; | ||||||
|  |             margin: 0 auto; | ||||||
|  |             padding: 0 2rem; | ||||||
|  |             display: flex; | ||||||
|  |             justify-content: space-between; | ||||||
|  |             align-items: center; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .header h1 { | ||||||
|  |             font-size: 2rem; | ||||||
|  |             font-weight: 600; | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             gap: 0.75rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .nav-links { | ||||||
|  |             display: flex; | ||||||
|  |             gap: 1.5rem; | ||||||
|  |             align-items: center; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .nav-links a { | ||||||
|  |             color: white; | ||||||
|  |             text-decoration: none; | ||||||
|  |             padding: 0.5rem 1rem; | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             transition: all 0.3s ease; | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .nav-links a:hover { | ||||||
|  |             background: rgba(255, 255, 255, 0.2); | ||||||
|  |             transform: translateY(-2px); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .stats-grid { | ||||||
|  |             display: grid; | ||||||
|  |             grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); | ||||||
|  |             gap: 1.5rem; | ||||||
|  |             margin-bottom: 2rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .stat-card { | ||||||
|  |             background: var(--white); | ||||||
|  |             padding: 2rem; | ||||||
|  |             border-radius: var(--border-radius-lg); | ||||||
|  |             box-shadow: var(--shadow); | ||||||
|  |             transition: all 0.3s ease; | ||||||
|  |             border-left: 4px solid var(--primary); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .stat-card:hover { | ||||||
|  |             transform: translateY(-4px); | ||||||
|  |             box-shadow: var(--shadow-lg); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .stat-card.success { border-left-color: var(--success); } | ||||||
|  |         .stat-card.warning { border-left-color: var(--warning); } | ||||||
|  |         .stat-card.info { border-left-color: var(--info); } | ||||||
|  |         .stat-card.error { border-left-color: var(--error); } | ||||||
|  | 
 | ||||||
|  |         .stat-header { | ||||||
|  |             display: flex; | ||||||
|  |             justify-content: space-between; | ||||||
|  |             align-items: center; | ||||||
|  |             margin-bottom: 1rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .stat-icon { | ||||||
|  |             width: 3rem; | ||||||
|  |             height: 3rem; | ||||||
|  |             border-radius: 50%; | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             justify-content: center; | ||||||
|  |             font-size: 1.25rem; | ||||||
|  |             color: white; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .stat-icon.primary { background: var(--primary); } | ||||||
|  |         .stat-icon.success { background: var(--success); } | ||||||
|  |         .stat-icon.warning { background: var(--warning); } | ||||||
|  |         .stat-icon.info { background: var(--info); } | ||||||
|  | 
 | ||||||
|  |         .stat-number { | ||||||
|  |             font-size: 2.5rem; | ||||||
|  |             font-weight: 700; | ||||||
|  |             color: var(--gray-800); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .stat-label { | ||||||
|  |             color: var(--gray-600); | ||||||
|  |             font-size: 0.875rem; | ||||||
|  |             font-weight: 500; | ||||||
|  |             text-transform: uppercase; | ||||||
|  |             letter-spacing: 0.05em; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .main-content { | ||||||
|  |             display: grid; | ||||||
|  |             grid-template-columns: 2fr 1fr; | ||||||
|  |             gap: 2rem; | ||||||
|  |             margin-bottom: 2rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .card { | ||||||
|  |             background: var(--white); | ||||||
|  |             border-radius: var(--border-radius-lg); | ||||||
|  |             box-shadow: var(--shadow); | ||||||
|  |             overflow: hidden; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .card-header { | ||||||
|  |             padding: 1.5rem; | ||||||
|  |             border-bottom: 1px solid var(--gray-200); | ||||||
|  |             display: flex; | ||||||
|  |             justify-content: between; | ||||||
|  |             align-items: center; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .card-title { | ||||||
|  |             font-size: 1.25rem; | ||||||
|  |             font-weight: 600; | ||||||
|  |             color: var(--gray-800); | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .card-body { | ||||||
|  |             padding: 1.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .search-box { | ||||||
|  |             position: relative; | ||||||
|  |             margin-bottom: 1.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .search-input { | ||||||
|  |             width: 100%; | ||||||
|  |             padding: 0.75rem 1rem 0.75rem 2.5rem; | ||||||
|  |             border: 2px solid var(--gray-300); | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             font-size: 0.875rem; | ||||||
|  |             transition: all 0.3s ease; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .search-input:focus { | ||||||
|  |             outline: none; | ||||||
|  |             border-color: var(--primary); | ||||||
|  |             box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .search-icon { | ||||||
|  |             position: absolute; | ||||||
|  |             left: 0.75rem; | ||||||
|  |             top: 50%; | ||||||
|  |             transform: translateY(-50%); | ||||||
|  |             color: var(--gray-400); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .table-container { | ||||||
|  |             overflow-x: auto; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .table { | ||||||
|  |             width: 100%; | ||||||
|  |             border-collapse: collapse; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .table th, | ||||||
|  |         .table td { | ||||||
|  |             padding: 1rem; | ||||||
|  |             text-align: left; | ||||||
|  |             border-bottom: 1px solid var(--gray-200); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .table th { | ||||||
|  |             background: var(--gray-50); | ||||||
|  |             font-weight: 600; | ||||||
|  |             color: var(--gray-700); | ||||||
|  |             font-size: 0.875rem; | ||||||
|  |             text-transform: uppercase; | ||||||
|  |             letter-spacing: 0.05em; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .table tbody tr:hover { | ||||||
|  |             background: var(--gray-50); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn { | ||||||
|  |             display: inline-flex; | ||||||
|  |             align-items: center; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |             padding: 0.75rem 1.5rem; | ||||||
|  |             border: none; | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             font-size: 0.875rem; | ||||||
|  |             font-weight: 500; | ||||||
|  |             text-decoration: none; | ||||||
|  |             cursor: pointer; | ||||||
|  |             transition: all 0.3s ease; | ||||||
|  |             text-align: center; | ||||||
|  |             justify-content: center; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn-primary { | ||||||
|  |             background: var(--primary); | ||||||
|  |             color: white; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn-primary:hover { | ||||||
|  |             background: var(--primary-dark); | ||||||
|  |             transform: translateY(-2px); | ||||||
|  |             box-shadow: var(--shadow-lg); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn-success { | ||||||
|  |             background: var(--success); | ||||||
|  |             color: white; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn-warning { | ||||||
|  |             background: var(--warning); | ||||||
|  |             color: white; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn-error { | ||||||
|  |             background: var(--error); | ||||||
|  |             color: white; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn-small { | ||||||
|  |             padding: 0.5rem 1rem; | ||||||
|  |             font-size: 0.75rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash-messages { | ||||||
|  |             margin-bottom: 1.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash { | ||||||
|  |             padding: 1rem; | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             margin-bottom: 0.5rem; | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash.success { | ||||||
|  |             background: #f0fff4; | ||||||
|  |             color: #22543d; | ||||||
|  |             border: 1px solid #c6f6d5; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash.error { | ||||||
|  |             background: #fff5f5; | ||||||
|  |             color: #742a2a; | ||||||
|  |             border: 1px solid #fed7d7; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash.warning { | ||||||
|  |             background: #fffbeb; | ||||||
|  |             color: #744210; | ||||||
|  |             border: 1px solid #feebc8; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash.info { | ||||||
|  |             background: #ebf8ff; | ||||||
|  |             color: #2a4a5a; | ||||||
|  |             border: 1px solid #bee3f8; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .recent-newsletters { | ||||||
|  |             max-height: 400px; | ||||||
|  |             overflow-y: auto; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .newsletter-item { | ||||||
|  |             padding: 1rem; | ||||||
|  |             border-bottom: 1px solid var(--gray-200); | ||||||
|  |             transition: all 0.3s ease; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .newsletter-item:hover { | ||||||
|  |             background: var(--gray-50); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .newsletter-item:last-child { | ||||||
|  |             border-bottom: none; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .newsletter-subject { | ||||||
|  |             font-weight: 600; | ||||||
|  |             color: var(--gray-800); | ||||||
|  |             margin-bottom: 0.25rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .newsletter-meta { | ||||||
|  |             font-size: 0.75rem; | ||||||
|  |             color: var(--gray-500); | ||||||
|  |             display: flex; | ||||||
|  |             gap: 1rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .pagination { | ||||||
|  |             display: flex; | ||||||
|  |             justify-content: center; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |             margin-top: 1.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .pagination a, | ||||||
|  |         .pagination span { | ||||||
|  |             padding: 0.5rem 0.75rem; | ||||||
|  |             border: 1px solid var(--gray-300); | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             color: var(--gray-600); | ||||||
|  |             text-decoration: none; | ||||||
|  |             transition: all 0.3s ease; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .pagination a:hover { | ||||||
|  |             background: var(--primary); | ||||||
|  |             color: white; | ||||||
|  |             border-color: var(--primary); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .pagination .current { | ||||||
|  |             background: var(--primary); | ||||||
|  |             color: white; | ||||||
|  |             border-color: var(--primary); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .add-subscriber { | ||||||
|  |             display: flex; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |             margin-bottom: 1rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .add-subscriber input { | ||||||
|  |             flex: 1; | ||||||
|  |             padding: 0.75rem; | ||||||
|  |             border: 2px solid var(--gray-300); | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             transition: all 0.3s ease; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .add-subscriber input:focus { | ||||||
|  |             outline: none; | ||||||
|  |             border-color: var(--primary); | ||||||
|  |             box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         @media (max-width: 768px) { | ||||||
|  |             .container { | ||||||
|  |                 padding: 1rem; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             .header-content { | ||||||
|  |                 flex-direction: column; | ||||||
|  |                 gap: 1rem; | ||||||
|  |                 text-align: center; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             .nav-links { | ||||||
|  |                 flex-wrap: wrap; | ||||||
|  |                 justify-content: center; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             .main-content { | ||||||
|  |                 grid-template-columns: 1fr; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             .stats-grid { | ||||||
|  |                 grid-template-columns: 1fr; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .loading { | ||||||
|  |             display: none; | ||||||
|  |             text-align: center; | ||||||
|  |             padding: 2rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .spinner { | ||||||
|  |             display: inline-block; | ||||||
|  |             width: 2rem; | ||||||
|  |             height: 2rem; | ||||||
|  |             border: 3px solid var(--gray-300); | ||||||
|  |             border-top: 3px solid var(--primary); | ||||||
|  |             border-radius: 50%; | ||||||
|  |             animation: spin 1s linear infinite; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         @keyframes spin { | ||||||
|  |             0% { transform: rotate(0deg); } | ||||||
|  |             100% { transform: rotate(360deg); } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .badge { | ||||||
|  |             display: inline-flex; | ||||||
|  |             align-items: center; | ||||||
|  |             padding: 0.25rem 0.75rem; | ||||||
|  |             border-radius: 9999px; | ||||||
|  |             font-size: 0.75rem; | ||||||
|  |             font-weight: 500; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .badge.success { | ||||||
|  |             background: #f0fff4; | ||||||
|  |             color: #22543d; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .badge.error { | ||||||
|  |             background: #fff5f5; | ||||||
|  |             color: #742a2a; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .empty-state { | ||||||
|  |             text-align: center; | ||||||
|  |             padding: 3rem; | ||||||
|  |             color: var(--gray-500); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .empty-state i { | ||||||
|  |             font-size: 3rem; | ||||||
|  |             margin-bottom: 1rem; | ||||||
|  |             color: var(--gray-400); | ||||||
|  |         } | ||||||
|  |     </style> | ||||||
| </head> | </head> | ||||||
| <body> | <body> | ||||||
|  |     <header class="header"> | ||||||
|  |         <div class="header-content"> | ||||||
|  |             <h1> | ||||||
|  |                 <i class="fas fa-envelope"></i> | ||||||
|  |                 Newsletter Admin | ||||||
|  |             </h1> | ||||||
|  |             <nav class="nav-links"> | ||||||
|  |                 <a href="/"><i class="fas fa-tachometer-alt"></i> Dashboard</a> | ||||||
|  |                 <a href="/subscribers"><i class="fas fa-users"></i> Subscribers</a> | ||||||
|  |                 <a href="/send_newsletter"><i class="fas fa-paper-plane"></i> Send Newsletter</a> | ||||||
|  |                 <a href="/newsletter_history"><i class="fas fa-history"></i> History</a> | ||||||
|  |                 <a href="/logout"><i class="fas fa-sign-out-alt"></i> Logout</a> | ||||||
|  |             </nav> | ||||||
|  |         </div> | ||||||
|  |     </header> | ||||||
| 
 | 
 | ||||||
|     <h1>Subscribers</h1> |     <div class="container"> | ||||||
|     <p> |         <!-- Flash Messages --> | ||||||
|       <a href="{{ url_for('send_update') }}">Send Update Email</a>| |         {% with messages = get_flashed_messages(with_categories=true) %} | ||||||
|       <a href="{{ url_for('logout') }}">Logout</a> |         {% if messages %} | ||||||
|     </p> |         <div class="flash-messages"> | ||||||
|  |             {% for category, message in messages %} | ||||||
|  |             <div class="flash {{ category }}"> | ||||||
|  |                 {% if category == 'success' %} | ||||||
|  |                 <i class="fas fa-check-circle"></i> | ||||||
|  |                 {% elif category == 'error' %} | ||||||
|  |                 <i class="fas fa-exclamation-circle"></i> | ||||||
|  |                 {% elif category == 'warning' %} | ||||||
|  |                 <i class="fas fa-exclamation-triangle"></i> | ||||||
|  |                 {% else %} | ||||||
|  |                 <i class="fas fa-info-circle"></i> | ||||||
|  |                 {% endif %} | ||||||
|  |                 {{ message }} | ||||||
|  |             </div> | ||||||
|  |             {% endfor %} | ||||||
|  |         </div> | ||||||
|  |         {% endif %} | ||||||
|  |         {% endwith %} | ||||||
| 
 | 
 | ||||||
|     {% with messages = get_flashed_messages(with_categories=true) %} |         <!-- Statistics Cards --> | ||||||
|       {% if messages %} |         <div class="stats-grid"> | ||||||
|         {% for category, message in messages %} |             <div class="stat-card success"> | ||||||
|           <div class="flash">{{ message }}</div> |                 <div class="stat-header"> | ||||||
|         {% endfor %} |                     <div> | ||||||
|       {% endif %} |                         <div class="stat-number">{{ stats.total_active }}</div> | ||||||
|     {% endwith %} |                         <div class="stat-label">Active Subscribers</div> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="stat-icon success"> | ||||||
|  |                         <i class="fas fa-users"></i> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
| 
 | 
 | ||||||
|     {% if emails %} |             <div class="stat-card info"> | ||||||
|         <table> |                 <div class="stat-header"> | ||||||
|             <thead> |                     <div> | ||||||
|                 <tr> |                         <div class="stat-number">{{ stats.recent_signups }}</div> | ||||||
|                     <th>Email Address</th> |                         <div class="stat-label">New This Month</div> | ||||||
|                 </tr> |                     </div> | ||||||
|             </thead> |                     <div class="stat-icon info"> | ||||||
|             <tbody> |                         <i class="fas fa-user-plus"></i> | ||||||
|                 {% for email in emails %} |                     </div> | ||||||
|                     <tr> |                 </div> | ||||||
|                         <td>{{ email }}</td> |             </div> | ||||||
|                     </tr> | 
 | ||||||
|                 {% endfor %} |             <div class="stat-card primary"> | ||||||
|             </tbody> |                 <div class="stat-header"> | ||||||
|         </table> |                     <div> | ||||||
|     {% else %} |                         <div class="stat-number">{{ stats.newsletters_sent }}</div> | ||||||
|         <p>No subscribers found.</p> |                         <div class="stat-label">Newsletters Sent</div> | ||||||
|     {% endif %} |                     </div> | ||||||
|  |                     <div class="stat-icon primary"> | ||||||
|  |                         <i class="fas fa-paper-plane"></i> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             <div class="stat-card warning"> | ||||||
|  |                 <div class="stat-header"> | ||||||
|  |                     <div> | ||||||
|  |                         <div class="stat-number">{{ stats.total_unsubscribed }}</div> | ||||||
|  |                         <div class="stat-label">Unsubscribed</div> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="stat-icon warning"> | ||||||
|  |                         <i class="fas fa-user-minus"></i> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <!-- Main Content --> | ||||||
|  |         <div class="main-content"> | ||||||
|  |             <!-- Recent Subscribers --> | ||||||
|  |             <div class="card"> | ||||||
|  |                 <div class="card-header"> | ||||||
|  |                     <h2 class="card-title"> | ||||||
|  |                         <i class="fas fa-users"></i> | ||||||
|  |                         Recent Subscribers | ||||||
|  |                     </h2> | ||||||
|  |                     <a href="/subscribers" class="btn btn-primary btn-small"> | ||||||
|  |                         <i class="fas fa-eye"></i> View All | ||||||
|  |                     </a> | ||||||
|  |                 </div> | ||||||
|  |                 <div class="card-body"> | ||||||
|  |                     <!-- Add Subscriber Form --> | ||||||
|  |                     <div class="add-subscriber"> | ||||||
|  |                         <input type="email" id="newEmail" placeholder="Add new subscriber email..." /> | ||||||
|  |                         <button onclick="addSubscriber()" class="btn btn-success"> | ||||||
|  |                             <i class="fas fa-plus"></i> Add | ||||||
|  |                         </button> | ||||||
|  |                     </div> | ||||||
|  | 
 | ||||||
|  |                     <!-- Search Box --> | ||||||
|  |                     <div class="search-box"> | ||||||
|  |                         <i class="fas fa-search search-icon"></i> | ||||||
|  |                         <input type="text" class="search-input" placeholder="Search subscribers..."  | ||||||
|  |                                value="{{ search }}" onkeyup="filterSubscribers(this.value)"> | ||||||
|  |                     </div> | ||||||
|  | 
 | ||||||
|  |                     {% if subscribers %} | ||||||
|  |                     <div class="table-container"> | ||||||
|  |                         <table class="table"> | ||||||
|  |                             <thead> | ||||||
|  |                                 <tr> | ||||||
|  |                                     <th>Email Address</th> | ||||||
|  |                                     <th>Joined</th> | ||||||
|  |                                     <th>Source</th> | ||||||
|  |                                     <th>Actions</th> | ||||||
|  |                                 </tr> | ||||||
|  |                             </thead> | ||||||
|  |                             <tbody> | ||||||
|  |                                 {% for subscriber in subscribers %} | ||||||
|  |                                 <tr> | ||||||
|  |                                     <td>{{ subscriber.email }}</td> | ||||||
|  |                                     <td>{{ subscriber.subscribed_at.strftime('%b %d, %Y') if subscriber.subscribed_at else 'N/A' }}</td> | ||||||
|  |                                     <td> | ||||||
|  |                                         <span class="badge success">{{ subscriber.source or 'manual' }}</span> | ||||||
|  |                                     </td> | ||||||
|  |                                     <td> | ||||||
|  |                                         <button onclick="removeSubscriber('{{ subscriber.email }}')"  | ||||||
|  |                                                 class="btn btn-error btn-small"> | ||||||
|  |                                             <i class="fas fa-trash"></i> | ||||||
|  |                                         </button> | ||||||
|  |                                     </td> | ||||||
|  |                                 </tr> | ||||||
|  |                                 {% endfor %} | ||||||
|  |                             </tbody> | ||||||
|  |                         </table> | ||||||
|  |                     </div> | ||||||
|  | 
 | ||||||
|  |                     <!-- Pagination --> | ||||||
|  |                     {% if pagination.total_pages > 1 %} | ||||||
|  |                     <div class="pagination"> | ||||||
|  |                         {% if pagination.page > 1 %} | ||||||
|  |                         <a href="?page={{ pagination.page - 1 }}{% if search %}&search={{ search }}{% endif %}"> | ||||||
|  |                             <i class="fas fa-chevron-left"></i> Previous | ||||||
|  |                         </a> | ||||||
|  |                         {% endif %} | ||||||
|  | 
 | ||||||
|  |                         {% for page_num in range(1, pagination.total_pages + 1) %} | ||||||
|  |                         {% if page_num == pagination.page %} | ||||||
|  |                         <span class="current">{{ page_num }}</span> | ||||||
|  |                         {% else %} | ||||||
|  |                         <a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}">{{ page_num }}</a> | ||||||
|  |                         {% endif %} | ||||||
|  |                         {% endfor %} | ||||||
|  | 
 | ||||||
|  |                         {% if pagination.page < pagination.total_pages %} | ||||||
|  |                         <a href="?page={{ pagination.page + 1 }}{% if search %}&search={{ search }}{% endif %}"> | ||||||
|  |                             Next <i class="fas fa-chevron-right"></i> | ||||||
|  |                         </a> | ||||||
|  |                         {% endif %} | ||||||
|  |                     </div> | ||||||
|  |                     {% endif %} | ||||||
|  |                     {% else %} | ||||||
|  |                     <div class="empty-state"> | ||||||
|  |                         <i class="fas fa-users"></i> | ||||||
|  |                         <h3>No subscribers yet</h3> | ||||||
|  |                         <p>Start building your subscriber list!</p> | ||||||
|  |                     </div> | ||||||
|  |                     {% endif %} | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             <!-- Recent Newsletters --> | ||||||
|  |             <div class="card"> | ||||||
|  |                 <div class="card-header"> | ||||||
|  |                     <h2 class="card-title"> | ||||||
|  |                         <i class="fas fa-history"></i> | ||||||
|  |                         Recent Newsletters | ||||||
|  |                     </h2> | ||||||
|  |                     <a href="/send_newsletter" class="btn btn-primary btn-small"> | ||||||
|  |                         <i class="fas fa-plus"></i> New | ||||||
|  |                     </a> | ||||||
|  |                 </div> | ||||||
|  |                 <div class="card-body"> | ||||||
|  |                     {% if recent_newsletters %} | ||||||
|  |                     <div class="recent-newsletters"> | ||||||
|  |                         {% for newsletter in recent_newsletters %} | ||||||
|  |                         <div class="newsletter-item"> | ||||||
|  |                             <div class="newsletter-subject">{{ newsletter.subject }}</div> | ||||||
|  |                             <div class="newsletter-meta"> | ||||||
|  |                                 <span><i class="fas fa-calendar"></i> {{ newsletter.sent_at.strftime('%b %d, %Y at %H:%M') if newsletter.sent_at else 'N/A' }}</span> | ||||||
|  |                                 <span><i class="fas fa-user"></i> {{ newsletter.sent_by or 'System' }}</span> | ||||||
|  |                                 {% if newsletter.success_count is not none %} | ||||||
|  |                                 <span><i class="fas fa-check"></i> {{ newsletter.success_count }} sent</span> | ||||||
|  |                                 {% endif %} | ||||||
|  |                                 {% if newsletter.failure_count and newsletter.failure_count > 0 %} | ||||||
|  |                                 <span><i class="fas fa-times"></i> {{ newsletter.failure_count }} failed</span> | ||||||
|  |                                 {% endif %} | ||||||
|  |                             </div> | ||||||
|  |                         </div> | ||||||
|  |                         {% endfor %} | ||||||
|  |                     </div> | ||||||
|  |                     {% else %} | ||||||
|  |                     <div class="empty-state"> | ||||||
|  |                         <i class="fas fa-paper-plane"></i> | ||||||
|  |                         <h3>No newsletters sent yet</h3> | ||||||
|  |                         <p>Send your first newsletter to get started!</p> | ||||||
|  |                         <a href="/send_newsletter" class="btn btn-primary"> | ||||||
|  |                             <i class="fas fa-plus"></i> Create Newsletter | ||||||
|  |                         </a> | ||||||
|  |                     </div> | ||||||
|  |                     {% endif %} | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <script> | ||||||
|  |         async function addSubscriber() { | ||||||
|  |             const emailInput = document.getElementById('newEmail'); | ||||||
|  |             const email = emailInput.value.trim(); | ||||||
|  | 
 | ||||||
|  |             if (!email) { | ||||||
|  |                 alert('Please enter an email address'); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if (!isValidEmail(email)) { | ||||||
|  |                 alert('Please enter a valid email address'); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             try { | ||||||
|  |                 const response = await fetch('/add_subscriber', { | ||||||
|  |                     method: 'POST', | ||||||
|  |                     headers: { | ||||||
|  |                         'Content-Type': 'application/json', | ||||||
|  |                     }, | ||||||
|  |                     body: JSON.stringify({ email: email }) | ||||||
|  |                 }); | ||||||
|  | 
 | ||||||
|  |                 const result = await response.json(); | ||||||
|  | 
 | ||||||
|  |                 if (result.success) { | ||||||
|  |                     showFlash('success', result.message); | ||||||
|  |                     emailInput.value = ''; | ||||||
|  |                     setTimeout(() => location.reload(), 1000); | ||||||
|  |                 } else { | ||||||
|  |                     showFlash('error', result.message); | ||||||
|  |                 } | ||||||
|  |             } catch (error) { | ||||||
|  |                 showFlash('error', 'An error occurred while adding the subscriber'); | ||||||
|  |                 console.error('Error:', error); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         async function removeSubscriber(email) { | ||||||
|  |             if (!confirm(`Are you sure you want to unsubscribe ${email}?`)) { | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             try { | ||||||
|  |                 const response = await fetch('/remove_subscriber', { | ||||||
|  |                     method: 'POST', | ||||||
|  |                     headers: { | ||||||
|  |                         'Content-Type': 'application/json', | ||||||
|  |                     }, | ||||||
|  |                     body: JSON.stringify({ email: email }) | ||||||
|  |                 }); | ||||||
|  | 
 | ||||||
|  |                 const result = await response.json(); | ||||||
|  | 
 | ||||||
|  |                 if (result.success) { | ||||||
|  |                     showFlash('success', result.message); | ||||||
|  |                     setTimeout(() => location.reload(), 1000); | ||||||
|  |                 } else { | ||||||
|  |                     showFlash('error', result.message); | ||||||
|  |                 } | ||||||
|  |             } catch (error) { | ||||||
|  |                 showFlash('error', 'An error occurred while removing the subscriber'); | ||||||
|  |                 console.error('Error:', error); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         function filterSubscribers(searchTerm) { | ||||||
|  |             if (searchTerm.length > 2 || searchTerm.length === 0) { | ||||||
|  |                 const url = new URL(window.location); | ||||||
|  |                 if (searchTerm) { | ||||||
|  |                     url.searchParams.set('search', searchTerm); | ||||||
|  |                 } else { | ||||||
|  |                     url.searchParams.delete('search'); | ||||||
|  |                 } | ||||||
|  |                 url.searchParams.set('page', '1'); | ||||||
|  |                  | ||||||
|  |                 // Debounce the search | ||||||
|  |                 clearTimeout(window.searchTimeout); | ||||||
|  |                 window.searchTimeout = setTimeout(() => { | ||||||
|  |                     window.location = url; | ||||||
|  |                 }, 500); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         function isValidEmail(email) { | ||||||
|  |             const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; | ||||||
|  |             return emailRegex.test(email); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         function showFlash(type, message) { | ||||||
|  |             const flashContainer = document.querySelector('.flash-messages') || createFlashContainer(); | ||||||
|  |             const flash = document.createElement('div'); | ||||||
|  |             flash.className = `flash ${type}`; | ||||||
|  |              | ||||||
|  |             const icon = type === 'success' ? 'check-circle' :  | ||||||
|  |                         type === 'error' ? 'exclamation-circle' :  | ||||||
|  |                         type === 'warning' ? 'exclamation-triangle' : 'info-circle'; | ||||||
|  |              | ||||||
|  |             flash.innerHTML = `<i class="fas fa-${icon}"></i> ${message}`; | ||||||
|  |             flashContainer.appendChild(flash); | ||||||
|  | 
 | ||||||
|  |             setTimeout(() => { | ||||||
|  |                 flash.remove(); | ||||||
|  |             }, 5000); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         function createFlashContainer() { | ||||||
|  |             const container = document.createElement('div'); | ||||||
|  |             container.className = 'flash-messages'; | ||||||
|  |             document.querySelector('.container').insertBefore(container, document.querySelector('.stats-grid')); | ||||||
|  |             return container; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Handle Enter key for add subscriber | ||||||
|  |         document.getElementById('newEmail').addEventListener('keypress', function(e) { | ||||||
|  |             if (e.key === 'Enter') { | ||||||
|  |                 addSubscriber(); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         // Auto-refresh stats every 30 seconds | ||||||
|  |         setInterval(async () => { | ||||||
|  |             try { | ||||||
|  |                 const response = await fetch('/api/stats'); | ||||||
|  |                 const stats = await response.json(); | ||||||
|  |                  | ||||||
|  |                 document.querySelector('.stat-card.success .stat-number').textContent = stats.total_active; | ||||||
|  |                 document.querySelector('.stat-card.info .stat-number').textContent = stats.recent_signups; | ||||||
|  |                 document.querySelector('.stat-card.primary .stat-number').textContent = stats.newsletters_sent; | ||||||
|  |                 document.querySelector('.stat-card.warning .stat-number').textContent = stats.total_unsubscribed; | ||||||
|  |             } catch (error) { | ||||||
|  |                 console.error('Error updating stats:', error); | ||||||
|  |             } | ||||||
|  |         }, 30000); | ||||||
|  |     </script> | ||||||
| </body> | </body> | ||||||
| </html> | </html> | ||||||
|  | @ -1,29 +1,383 @@ | ||||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||||
| <html lang="en"> | <html lang="en"> | ||||||
| 
 |  | ||||||
| <head> | <head> | ||||||
|   <meta charset="UTF-8"> |     <meta charset="UTF-8"> | ||||||
|   <meta name="viewport" content="width=device-width, initial-scale=1.0"> |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|   <title>Admin Login</title> |     <title>Admin Login - Newsletter Admin</title> | ||||||
|   <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> |     <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> | ||||||
|  |     <style> | ||||||
|  |         * { | ||||||
|  |             margin: 0; | ||||||
|  |             padding: 0; | ||||||
|  |             box-sizing: border-box; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         :root { | ||||||
|  |             --primary: #667eea; | ||||||
|  |             --primary-dark: #5a67d8; | ||||||
|  |             --secondary: #764ba2; | ||||||
|  |             --success: #48bb78; | ||||||
|  |             --error: #f56565; | ||||||
|  |             --gray-100: #f7fafc; | ||||||
|  |             --gray-200: #edf2f7; | ||||||
|  |             --gray-300: #e2e8f0; | ||||||
|  |             --gray-700: #4a5568; | ||||||
|  |             --gray-800: #2d3748; | ||||||
|  |             --white: #ffffff; | ||||||
|  |             --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1); | ||||||
|  |             --border-radius: 8px; | ||||||
|  |             --border-radius-lg: 12px; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         body { | ||||||
|  |             font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | ||||||
|  |             background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); | ||||||
|  |             min-height: 100vh; | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             justify-content: center; | ||||||
|  |             padding: 1rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .login-container { | ||||||
|  |             background: var(--white); | ||||||
|  |             border-radius: var(--border-radius-lg); | ||||||
|  |             box-shadow: var(--shadow-lg); | ||||||
|  |             padding: 3rem; | ||||||
|  |             width: 100%; | ||||||
|  |             max-width: 400px; | ||||||
|  |             text-align: center; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .login-header { | ||||||
|  |             margin-bottom: 2rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .login-icon { | ||||||
|  |             width: 4rem; | ||||||
|  |             height: 4rem; | ||||||
|  |             background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); | ||||||
|  |             border-radius: 50%; | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             justify-content: center; | ||||||
|  |             margin: 0 auto 1rem; | ||||||
|  |             color: white; | ||||||
|  |             font-size: 1.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .login-title { | ||||||
|  |             font-size: 1.75rem; | ||||||
|  |             font-weight: 700; | ||||||
|  |             color: var(--gray-800); | ||||||
|  |             margin-bottom: 0.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .login-subtitle { | ||||||
|  |             color: var(--gray-700); | ||||||
|  |             font-size: 0.875rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .form-group { | ||||||
|  |             margin-bottom: 1.5rem; | ||||||
|  |             text-align: left; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .form-label { | ||||||
|  |             display: block; | ||||||
|  |             margin-bottom: 0.5rem; | ||||||
|  |             font-weight: 500; | ||||||
|  |             color: var(--gray-700); | ||||||
|  |             font-size: 0.875rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .form-input { | ||||||
|  |             width: 100%; | ||||||
|  |             padding: 0.75rem 1rem; | ||||||
|  |             border: 2px solid var(--gray-300); | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             font-size: 1rem; | ||||||
|  |             transition: all 0.3s ease; | ||||||
|  |             background: var(--white); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .form-input:focus { | ||||||
|  |             outline: none; | ||||||
|  |             border-color: var(--primary); | ||||||
|  |             box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .input-group { | ||||||
|  |             position: relative; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .input-icon { | ||||||
|  |             position: absolute; | ||||||
|  |             left: 1rem; | ||||||
|  |             top: 50%; | ||||||
|  |             transform: translateY(-50%); | ||||||
|  |             color: var(--gray-700); | ||||||
|  |             pointer-events: none; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .input-with-icon { | ||||||
|  |             padding-left: 2.75rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .login-btn { | ||||||
|  |             width: 100%; | ||||||
|  |             padding: 0.875rem; | ||||||
|  |             background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); | ||||||
|  |             color: white; | ||||||
|  |             border: none; | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             font-size: 1rem; | ||||||
|  |             font-weight: 600; | ||||||
|  |             cursor: pointer; | ||||||
|  |             transition: all 0.3s ease; | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             justify-content: center; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .login-btn:hover { | ||||||
|  |             transform: translateY(-2px); | ||||||
|  |             box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .login-btn:active { | ||||||
|  |             transform: translateY(0); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .login-btn:disabled { | ||||||
|  |             opacity: 0.6; | ||||||
|  |             cursor: not-allowed; | ||||||
|  |             transform: none; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash-messages { | ||||||
|  |             margin-bottom: 1.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash { | ||||||
|  |             padding: 1rem; | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             margin-bottom: 0.5rem; | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |             text-align: left; | ||||||
|  |             font-size: 0.875rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash.success { | ||||||
|  |             background: #f0fff4; | ||||||
|  |             color: #22543d; | ||||||
|  |             border: 1px solid #c6f6d5; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash.error { | ||||||
|  |             background: #fff5f5; | ||||||
|  |             color: #742a2a; | ||||||
|  |             border: 1px solid #fed7d7; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash.warning { | ||||||
|  |             background: #fffbeb; | ||||||
|  |             color: #744210; | ||||||
|  |             border: 1px solid #feebc8; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash.info { | ||||||
|  |             background: #ebf8ff; | ||||||
|  |             color: #2a4a5a; | ||||||
|  |             border: 1px solid #bee3f8; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .spinner { | ||||||
|  |             display: inline-block; | ||||||
|  |             width: 1rem; | ||||||
|  |             height: 1rem; | ||||||
|  |             border: 2px solid rgba(255, 255, 255, 0.3); | ||||||
|  |             border-top: 2px solid white; | ||||||
|  |             border-radius: 50%; | ||||||
|  |             animation: spin 1s linear infinite; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         @keyframes spin { | ||||||
|  |             0% { transform: rotate(0deg); } | ||||||
|  |             100% { transform: rotate(360deg); } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .forgot-password { | ||||||
|  |             margin-top: 1.5rem; | ||||||
|  |             padding-top: 1.5rem; | ||||||
|  |             border-top: 1px solid var(--gray-200); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .forgot-password a { | ||||||
|  |             color: var(--primary); | ||||||
|  |             text-decoration: none; | ||||||
|  |             font-size: 0.875rem; | ||||||
|  |             transition: color 0.3s ease; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .forgot-password a:hover { | ||||||
|  |             color: var(--primary-dark); | ||||||
|  |             text-decoration: underline; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         @media (max-width: 480px) { | ||||||
|  |             .login-container { | ||||||
|  |                 padding: 2rem; | ||||||
|  |                 margin: 1rem; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             .login-title { | ||||||
|  |                 font-size: 1.5rem; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .password-toggle { | ||||||
|  |             position: absolute; | ||||||
|  |             right: 1rem; | ||||||
|  |             top: 50%; | ||||||
|  |             transform: translateY(-50%); | ||||||
|  |             background: none; | ||||||
|  |             border: none; | ||||||
|  |             color: var(--gray-700); | ||||||
|  |             cursor: pointer; | ||||||
|  |             padding: 0; | ||||||
|  |             font-size: 0.875rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .password-toggle:hover { | ||||||
|  |             color: var(--primary); | ||||||
|  |         } | ||||||
|  |     </style> | ||||||
| </head> | </head> | ||||||
| 
 |  | ||||||
| <body> | <body> | ||||||
|   <h1>Admin Login</h1> |     <div class="login-container"> | ||||||
|   {% with messages = get_flashed_messages(with_categories=true) %} |         <div class="login-header"> | ||||||
|   {% if messages %} |             <div class="login-icon"> | ||||||
|   {% for category, message in messages %} |                 <i class="fas fa-envelope"></i> | ||||||
|   <div class="flash">{{ message }}</div> |             </div> | ||||||
|   {% endfor %} |             <h1 class="login-title">Newsletter Admin</h1> | ||||||
|   {% endif %} |             <p class="login-subtitle">Sign in to manage your newsletter</p> | ||||||
|   {% endwith %} |         </div> | ||||||
|   <form action="{{ url_for('login') }}" method="POST"> |  | ||||||
|     <label for="username">Username:</label> |  | ||||||
|     <input type="text" name="username" required /> |  | ||||||
|     <label for="password">Password:</label> |  | ||||||
|     <input type="password" name="password" required /> |  | ||||||
|     <button type="submit">Login</button> |  | ||||||
|   </form> |  | ||||||
| </body> |  | ||||||
| 
 | 
 | ||||||
| </html> |         <!-- Flash Messages --> | ||||||
|  |         {% with messages = get_flashed_messages(with_categories=true) %} | ||||||
|  |         {% if messages %} | ||||||
|  |         <div class="flash-messages"> | ||||||
|  |             {% for category, message in messages %} | ||||||
|  |             <div class="flash {{ category }}"> | ||||||
|  |                 {% if category == 'success' %} | ||||||
|  |                 <i class="fas fa-check-circle"></i> | ||||||
|  |                 {% elif category == 'error' %} | ||||||
|  |                 <i class="fas fa-exclamation-circle"></i> | ||||||
|  |                 {% elif category == 'warning' %} | ||||||
|  |                 <i class="fas fa-exclamation-triangle"></i> | ||||||
|  |                 {% else %} | ||||||
|  |                 <i class="fas fa-info-circle"></i> | ||||||
|  |                 {% endif %} | ||||||
|  |                 {{ message }} | ||||||
|  |             </div> | ||||||
|  |             {% endfor %} | ||||||
|  |         </div> | ||||||
|  |         {% endif %} | ||||||
|  |         {% endwith %} | ||||||
|  | 
 | ||||||
|  |         <form method="POST" onsubmit="handleLogin(event)"> | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label for="username" class="form-label">Username</label> | ||||||
|  |                 <div class="input-group"> | ||||||
|  |                     <i class="fas fa-user input-icon"></i> | ||||||
|  |                     <input type="text"  | ||||||
|  |                            id="username"  | ||||||
|  |                            name="username"  | ||||||
|  |                            class="form-input input-with-icon"  | ||||||
|  |                            placeholder="Enter your username" | ||||||
|  |                            required  | ||||||
|  |                            autocomplete="username"> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label for="password" class="form-label">Password</label> | ||||||
|  |                 <div class="input-group"> | ||||||
|  |                     <i class="fas fa-lock input-icon"></i> | ||||||
|  |                     <input type="password"  | ||||||
|  |                            id="password"  | ||||||
|  |                            name="password"  | ||||||
|  |                            class="form-input input-with-icon"  | ||||||
|  |                            placeholder="Enter your password" | ||||||
|  |                            required  | ||||||
|  |                            autocomplete="current-password"> | ||||||
|  |                     <button type="button" class="password-toggle" onclick="togglePassword()"> | ||||||
|  |                         <i class="fas fa-eye" id="password-icon"></i> | ||||||
|  |                     </button> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             <button type="submit" class="login-btn" id="login-btn"> | ||||||
|  |                 <span id="login-text">Sign In</span> | ||||||
|  |                 <span id="login-spinner" class="spinner" style="display: none;"></span> | ||||||
|  |             </button> | ||||||
|  |         </form> | ||||||
|  | 
 | ||||||
|  |         <div class="forgot-password"> | ||||||
|  |             <p>Having trouble signing in? Contact your administrator.</p> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <script> | ||||||
|  |         function togglePassword() { | ||||||
|  |             const passwordInput = document.getElementById('password'); | ||||||
|  |             const passwordIcon = document.getElementById('password-icon'); | ||||||
|  |              | ||||||
|  |             if (passwordInput.type === 'password') { | ||||||
|  |                 passwordInput.type = 'text'; | ||||||
|  |                 passwordIcon.className = 'fas fa-eye-slash'; | ||||||
|  |             } else { | ||||||
|  |                 passwordInput.type = 'password'; | ||||||
|  |                 passwordIcon.className = 'fas fa-eye'; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         function handleLogin(event) { | ||||||
|  |             const loginBtn = document.getElementById('login-btn'); | ||||||
|  |             const loginText = document.getElementById('login-text'); | ||||||
|  |             const loginSpinner = document.getElementById('login-spinner'); | ||||||
|  |              | ||||||
|  |             // Show loading state | ||||||
|  |             loginBtn.disabled = true; | ||||||
|  |             loginText.style.display = 'none'; | ||||||
|  |             loginSpinner.style.display = 'inline-block'; | ||||||
|  |              | ||||||
|  |             // Reset form state after a short delay if needed | ||||||
|  |             setTimeout(() => { | ||||||
|  |                 if (loginBtn.disabled) { | ||||||
|  |                     loginBtn.disabled = false; | ||||||
|  |                     loginText.style.display = 'inline'; | ||||||
|  |                     loginSpinner.style.display = 'none'; | ||||||
|  |                 } | ||||||
|  |             }, 5000); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Auto-focus username field | ||||||
|  |         document.addEventListener('DOMContentLoaded', function() { | ||||||
|  |             document.getElementById('username').focus(); | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         // Handle Enter key navigation | ||||||
|  |         document.getElementById('username').addEventListener('keypress', function(e) { | ||||||
|  |             if (e.key === 'Enter') { | ||||||
|  |                 e.preventDefault(); | ||||||
|  |                 document.getElementById('password').focus(); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     </script> | ||||||
|  | </body> | ||||||
|  | </html> | ||||||
							
								
								
									
										667
									
								
								templates/send_newsletter.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										667
									
								
								templates/send_newsletter.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,667 @@ | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="en"> | ||||||
|  | <head> | ||||||
|  |     <meta charset="UTF-8"> | ||||||
|  |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|  |     <title>Send Newsletter - Newsletter Admin</title> | ||||||
|  |     <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> | ||||||
|  |     <style> | ||||||
|  |         * { | ||||||
|  |             margin: 0; | ||||||
|  |             padding: 0; | ||||||
|  |             box-sizing: border-box; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         :root { | ||||||
|  |             --primary: #667eea; | ||||||
|  |             --primary-dark: #5a67d8; | ||||||
|  |             --secondary: #764ba2; | ||||||
|  |             --success: #48bb78; | ||||||
|  |             --warning: #ed8936; | ||||||
|  |             --error: #f56565; | ||||||
|  |             --info: #4299e1; | ||||||
|  |             --dark: #2d3748; | ||||||
|  |             --light: #f7fafc; | ||||||
|  |             --gray-100: #f7fafc; | ||||||
|  |             --gray-200: #edf2f7; | ||||||
|  |             --gray-300: #e2e8f0; | ||||||
|  |             --gray-400: #cbd5e0; | ||||||
|  |             --gray-500: #a0aec0; | ||||||
|  |             --gray-600: #718096; | ||||||
|  |             --gray-700: #4a5568; | ||||||
|  |             --gray-800: #2d3748; | ||||||
|  |             --gray-900: #1a202c; | ||||||
|  |             --white: #ffffff; | ||||||
|  |             --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); | ||||||
|  |             --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); | ||||||
|  |             --border-radius: 8px; | ||||||
|  |             --border-radius-lg: 12px; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         body { | ||||||
|  |             font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | ||||||
|  |             background: linear-gradient(135deg, var(--gray-100) 0%, var(--gray-200) 100%); | ||||||
|  |             color: var(--gray-800); | ||||||
|  |             line-height: 1.6; | ||||||
|  |             min-height: 100vh; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .container { | ||||||
|  |             max-width: 1200px; | ||||||
|  |             margin: 0 auto; | ||||||
|  |             padding: 2rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .header { | ||||||
|  |             background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); | ||||||
|  |             color: white; | ||||||
|  |             padding: 2rem 0; | ||||||
|  |             margin-bottom: 2rem; | ||||||
|  |             border-radius: var(--border-radius-lg); | ||||||
|  |             box-shadow: var(--shadow-lg); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .header-content { | ||||||
|  |             max-width: 1200px; | ||||||
|  |             margin: 0 auto; | ||||||
|  |             padding: 0 2rem; | ||||||
|  |             display: flex; | ||||||
|  |             justify-content: space-between; | ||||||
|  |             align-items: center; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .header h1 { | ||||||
|  |             font-size: 2rem; | ||||||
|  |             font-weight: 600; | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             gap: 0.75rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .nav-links { | ||||||
|  |             display: flex; | ||||||
|  |             gap: 1.5rem; | ||||||
|  |             align-items: center; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .nav-links a { | ||||||
|  |             color: white; | ||||||
|  |             text-decoration: none; | ||||||
|  |             padding: 0.5rem 1rem; | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             transition: all 0.3s ease; | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .nav-links a:hover { | ||||||
|  |             background: rgba(255, 255, 255, 0.2); | ||||||
|  |             transform: translateY(-2px); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash-messages { | ||||||
|  |             margin-bottom: 1.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash { | ||||||
|  |             padding: 1rem; | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             margin-bottom: 0.5rem; | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash.success { | ||||||
|  |             background: #f0fff4; | ||||||
|  |             color: #22543d; | ||||||
|  |             border: 1px solid #c6f6d5; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash.error { | ||||||
|  |             background: #fff5f5; | ||||||
|  |             color: #742a2a; | ||||||
|  |             border: 1px solid #fed7d7; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash.warning { | ||||||
|  |             background: #fffbeb; | ||||||
|  |             color: #744210; | ||||||
|  |             border: 1px solid #feebc8; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .flash.info { | ||||||
|  |             background: #ebf8ff; | ||||||
|  |             color: #2a4a5a; | ||||||
|  |             border: 1px solid #bee3f8; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .main-content { | ||||||
|  |             display: grid; | ||||||
|  |             grid-template-columns: 2fr 1fr; | ||||||
|  |             gap: 2rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .card { | ||||||
|  |             background: var(--white); | ||||||
|  |             border-radius: var(--border-radius-lg); | ||||||
|  |             box-shadow: var(--shadow); | ||||||
|  |             overflow: hidden; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .card-header { | ||||||
|  |             padding: 1.5rem; | ||||||
|  |             border-bottom: 1px solid var(--gray-200); | ||||||
|  |             background: var(--gray-50); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .card-title { | ||||||
|  |             font-size: 1.25rem; | ||||||
|  |             font-weight: 600; | ||||||
|  |             color: var(--gray-800); | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .card-body { | ||||||
|  |             padding: 1.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .form-group { | ||||||
|  |             margin-bottom: 1.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .form-label { | ||||||
|  |             display: block; | ||||||
|  |             margin-bottom: 0.5rem; | ||||||
|  |             font-weight: 500; | ||||||
|  |             color: var(--gray-700); | ||||||
|  |             font-size: 0.875rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .form-input { | ||||||
|  |             width: 100%; | ||||||
|  |             padding: 0.75rem; | ||||||
|  |             border: 2px solid var(--gray-300); | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             font-size: 1rem; | ||||||
|  |             transition: all 0.3s ease; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .form-input:focus { | ||||||
|  |             outline: none; | ||||||
|  |             border-color: var(--primary); | ||||||
|  |             box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .form-textarea { | ||||||
|  |             min-height: 300px; | ||||||
|  |             resize: vertical; | ||||||
|  |             font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn { | ||||||
|  |             display: inline-flex; | ||||||
|  |             align-items: center; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |             padding: 0.75rem 1.5rem; | ||||||
|  |             border: none; | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             font-size: 0.875rem; | ||||||
|  |             font-weight: 500; | ||||||
|  |             text-decoration: none; | ||||||
|  |             cursor: pointer; | ||||||
|  |             transition: all 0.3s ease; | ||||||
|  |             text-align: center; | ||||||
|  |             justify-content: center; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn-primary { | ||||||
|  |             background: var(--primary); | ||||||
|  |             color: white; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn-primary:hover { | ||||||
|  |             background: var(--primary-dark); | ||||||
|  |             transform: translateY(-2px); | ||||||
|  |             box-shadow: var(--shadow-lg); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn-secondary { | ||||||
|  |             background: var(--gray-600); | ||||||
|  |             color: white; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn-secondary:hover { | ||||||
|  |             background: var(--gray-700); | ||||||
|  |             transform: translateY(-2px); | ||||||
|  |             box-shadow: var(--shadow-lg); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn-success { | ||||||
|  |             background: var(--success); | ||||||
|  |             color: white; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn-success:hover { | ||||||
|  |             background: #38a169; | ||||||
|  |             transform: translateY(-2px); | ||||||
|  |             box-shadow: var(--shadow-lg); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn:disabled { | ||||||
|  |             opacity: 0.6; | ||||||
|  |             cursor: not-allowed; | ||||||
|  |             transform: none; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .btn-group { | ||||||
|  |             display: flex; | ||||||
|  |             gap: 1rem; | ||||||
|  |             justify-content: flex-end; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .preview-container { | ||||||
|  |             border: 2px dashed var(--gray-300); | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             padding: 2rem; | ||||||
|  |             margin-top: 1rem; | ||||||
|  |             background: var(--gray-50); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .preview-container.has-content { | ||||||
|  |             border-color: var(--primary); | ||||||
|  |             background: var(--white); | ||||||
|  |             border-style: solid; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .email-preview { | ||||||
|  |             background: var(--white); | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             padding: 1.5rem; | ||||||
|  |             box-shadow: var(--shadow); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .email-preview h3 { | ||||||
|  |             color: var(--gray-800); | ||||||
|  |             margin-bottom: 1rem; | ||||||
|  |             padding-bottom: 0.5rem; | ||||||
|  |             border-bottom: 2px solid var(--primary); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .email-body { | ||||||
|  |             margin-bottom: 2rem; | ||||||
|  |             line-height: 1.7; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .toolbar { | ||||||
|  |             display: flex; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |             margin-bottom: 1rem; | ||||||
|  |             padding: 0.75rem; | ||||||
|  |             background: var(--gray-100); | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             border: 1px solid var(--gray-300); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .toolbar button { | ||||||
|  |             padding: 0.5rem; | ||||||
|  |             border: none; | ||||||
|  |             background: var(--white); | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             cursor: pointer; | ||||||
|  |             transition: all 0.3s ease; | ||||||
|  |             color: var(--gray-600); | ||||||
|  |             min-width: 2.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .toolbar button:hover { | ||||||
|  |             background: var(--primary); | ||||||
|  |             color: white; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .stats-info { | ||||||
|  |             background: var(--gray-100); | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             padding: 1rem; | ||||||
|  |             margin-bottom: 1rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .stats-info h3 { | ||||||
|  |             color: var(--gray-800); | ||||||
|  |             margin-bottom: 0.5rem; | ||||||
|  |             font-size: 1rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .stats-item { | ||||||
|  |             display: flex; | ||||||
|  |             justify-content: space-between; | ||||||
|  |             margin-bottom: 0.25rem; | ||||||
|  |             font-size: 0.875rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .stats-item strong { | ||||||
|  |             color: var(--primary); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .tips { | ||||||
|  |             background: var(--info); | ||||||
|  |             color: white; | ||||||
|  |             padding: 1rem; | ||||||
|  |             border-radius: var(--border-radius); | ||||||
|  |             margin-bottom: 1rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .tips h3 { | ||||||
|  |             margin-bottom: 0.5rem; | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             gap: 0.5rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .tips ul { | ||||||
|  |             margin-left: 1rem; | ||||||
|  |             font-size: 0.875rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .tips li { | ||||||
|  |             margin-bottom: 0.25rem; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .spinner { | ||||||
|  |             display: inline-block; | ||||||
|  |             width: 1rem; | ||||||
|  |             height: 1rem; | ||||||
|  |             border: 2px solid rgba(255, 255, 255, 0.3); | ||||||
|  |             border-top: 2px solid white; | ||||||
|  |             border-radius: 50%; | ||||||
|  |             animation: spin 1s linear infinite; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         @keyframes spin { | ||||||
|  |             0% { transform: rotate(0deg); } | ||||||
|  |             100% { transform: rotate(360deg); } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         @media (max-width: 768px) { | ||||||
|  |             .container { | ||||||
|  |                 padding: 1rem; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             .header-content { | ||||||
|  |                 flex-direction: column; | ||||||
|  |                 gap: 1rem; | ||||||
|  |                 text-align: center; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             .nav-links { | ||||||
|  |                 flex-wrap: wrap; | ||||||
|  |                 justify-content: center; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             .main-content { | ||||||
|  |                 grid-template-columns: 1fr; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             .btn-group { | ||||||
|  |                 flex-direction: column; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     </style> | ||||||
|  | </head> | ||||||
|  | <body> | ||||||
|  |     <header class="header"> | ||||||
|  |         <div class="header-content"> | ||||||
|  |             <h1> | ||||||
|  |                 <i class="fas fa-paper-plane"></i> | ||||||
|  |                 Send Newsletter | ||||||
|  |             </h1> | ||||||
|  |             <nav class="nav-links"> | ||||||
|  |                 <a href="/"><i class="fas fa-tachometer-alt"></i> Dashboard</a> | ||||||
|  |                 <a href="/subscribers"><i class="fas fa-users"></i> Subscribers</a> | ||||||
|  |                 <a href="/newsletter_history"><i class="fas fa-history"></i> History</a> | ||||||
|  |                 <a href="/logout"><i class="fas fa-sign-out-alt"></i> Logout</a> | ||||||
|  |             </nav> | ||||||
|  |         </div> | ||||||
|  |     </header> | ||||||
|  | 
 | ||||||
|  |     <div class="container"> | ||||||
|  |         <!-- Flash Messages --> | ||||||
|  |         {% with messages = get_flashed_messages(with_categories=true) %} | ||||||
|  |         {% if messages %} | ||||||
|  |         <div class="flash-messages"> | ||||||
|  |             {% for category, message in messages %} | ||||||
|  |             <div class="flash {{ category }}"> | ||||||
|  |                 {% if category == 'success' %} | ||||||
|  |                 <i class="fas fa-check-circle"></i> | ||||||
|  |                 {% elif category == 'error' %} | ||||||
|  |                 <i class="fas fa-exclamation-circle"></i> | ||||||
|  |                 {% elif category == 'warning' %} | ||||||
|  |                 <i class="fas fa-exclamation-triangle"></i> | ||||||
|  |                 {% else %} | ||||||
|  |                 <i class="fas fa-info-circle"></i> | ||||||
|  |                 {% endif %} | ||||||
|  |                 {{ message }} | ||||||
|  |             </div> | ||||||
|  |             {% endfor %} | ||||||
|  |         </div> | ||||||
|  |         {% endif %} | ||||||
|  |         {% endwith %} | ||||||
|  | 
 | ||||||
|  |         <div class="main-content"> | ||||||
|  |             <!-- Newsletter Form --> | ||||||
|  |             <div class="card"> | ||||||
|  |                 <div class="card-header"> | ||||||
|  |                     <h2 class="card-title"> | ||||||
|  |                         <i class="fas fa-edit"></i> | ||||||
|  |                         Compose Newsletter | ||||||
|  |                     </h2> | ||||||
|  |                 </div> | ||||||
|  |                 <div class="card-body"> | ||||||
|  |                     <form method="POST" id="newsletter-form"> | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label for="subject" class="form-label">Subject Line</label> | ||||||
|  |                             <input type="text"  | ||||||
|  |                                    id="subject"  | ||||||
|  |                                    name="subject"  | ||||||
|  |                                    class="form-input"  | ||||||
|  |                                    placeholder="Enter a compelling subject line..." | ||||||
|  |                                    value="{{ subject or '' }}" | ||||||
|  |                                    required> | ||||||
|  |                         </div> | ||||||
|  | 
 | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label for="body" class="form-label">Email Content (HTML allowed)</label> | ||||||
|  |                              | ||||||
|  |                             <!-- Rich Text Toolbar --> | ||||||
|  |                             <div class="toolbar"> | ||||||
|  |                                 <button type="button" onclick="formatText('bold')" title="Bold"> | ||||||
|  |                                     <i class="fas fa-bold"></i> | ||||||
|  |                                 </button> | ||||||
|  |                                 <button type="button" onclick="formatText('italic')" title="Italic"> | ||||||
|  |                                     <i class="fas fa-italic"></i> | ||||||
|  |                                 </button> | ||||||
|  |                                 <button type="button" onclick="formatText('underline')" title="Underline"> | ||||||
|  |                                     <i class="fas fa-underline"></i> | ||||||
|  |                                 </button> | ||||||
|  |                                 <button type="button" onclick="insertLink()" title="Insert Link"> | ||||||
|  |                                     <i class="fas fa-link"></i> | ||||||
|  |                                 </button> | ||||||
|  |                                 <button type="button" onclick="insertList()" title="Insert List"> | ||||||
|  |                                     <i class="fas fa-list-ul"></i> | ||||||
|  |                                 </button> | ||||||
|  |                             </div> | ||||||
|  | 
 | ||||||
|  |                             <textarea id="body"  | ||||||
|  |                                       name="body"  | ||||||
|  |                                       class="form-input form-textarea"  | ||||||
|  |                                       placeholder="Write your newsletter content here... HTML tags are supported." | ||||||
|  |                                       required>{{ body or '' }}</textarea> | ||||||
|  |                         </div> | ||||||
|  | 
 | ||||||
|  |                         <div class="btn-group"> | ||||||
|  |                             <button type="submit" name="action" value="preview" class="btn btn-secondary"> | ||||||
|  |                                 <i class="fas fa-eye"></i> Preview | ||||||
|  |                             </button> | ||||||
|  |                             <button type="submit" name="action" value="send" class="btn btn-success" id="send-btn"> | ||||||
|  |                                 <span id="send-text"> | ||||||
|  |                                     <i class="fas fa-paper-plane"></i> Send Newsletter | ||||||
|  |                                 </span> | ||||||
|  |                                 <span id="send-spinner" class="spinner" style="display: none;"></span> | ||||||
|  |                             </button> | ||||||
|  |                         </div> | ||||||
|  |                     </form> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             <!-- Sidebar --> | ||||||
|  |             <div> | ||||||
|  |                 <!-- Statistics --> | ||||||
|  |                 <div class="card"> | ||||||
|  |                     <div class="card-header"> | ||||||
|  |                         <h3 class="card-title"> | ||||||
|  |                             <i class="fas fa-chart-bar"></i> | ||||||
|  |                             Quick Stats | ||||||
|  |                         </h3> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="card-body"> | ||||||
|  |                         <div class="stats-info" id="subscriber-stats"> | ||||||
|  |                             <h3>Subscriber Information</h3> | ||||||
|  |                             <div class="stats-item"> | ||||||
|  |                                 <span>Active Subscribers:</span> | ||||||
|  |                                 <strong id="active-count">Loading...</strong> | ||||||
|  |                             </div> | ||||||
|  |                             <div class="stats-item"> | ||||||
|  |                                 <span>New This Month:</span> | ||||||
|  |                                 <strong id="recent-count">Loading...</strong> | ||||||
|  |                             </div> | ||||||
|  |                             <div class="stats-item"> | ||||||
|  |                                 <span>Total Sent:</span> | ||||||
|  |                                 <strong id="sent-count">Loading...</strong> | ||||||
|  |                             </div> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  | 
 | ||||||
|  |                 <!-- Tips --> | ||||||
|  |                 <div class="tips"> | ||||||
|  |                     <h3><i class="fas fa-lightbulb"></i> Writing Tips</h3> | ||||||
|  |                     <ul> | ||||||
|  |                         <li>Keep subject lines under 50 characters</li> | ||||||
|  |                         <li>Use personalization when possible</li> | ||||||
|  |                         <li>Include a clear call-to-action</li> | ||||||
|  |                         <li>Test your content before sending</li> | ||||||
|  |                         <li>Mobile-friendly formatting is key</li> | ||||||
|  |                     </ul> | ||||||
|  |                 </div> | ||||||
|  | 
 | ||||||
|  |                 <!-- Preview Container --> | ||||||
|  |                 {% if preview %} | ||||||
|  |                 <div class="card"> | ||||||
|  |                     <div class="card-header"> | ||||||
|  |                         <h3 class="card-title"> | ||||||
|  |                             <i class="fas fa-eye"></i> | ||||||
|  |                             Email Preview | ||||||
|  |                         </h3> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="card-body"> | ||||||
|  |                         <div class="preview-container has-content"> | ||||||
|  |                             {{ preview|safe }} | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |                 {% endif %} | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <script> | ||||||
|  |         // Load subscriber stats | ||||||
|  |         async function loadStats() { | ||||||
|  |             try { | ||||||
|  |                 const response = await fetch('/api/stats'); | ||||||
|  |                 const stats = await response.json(); | ||||||
|  |                  | ||||||
|  |                 document.getElementById('active-count').textContent = stats.total_active; | ||||||
|  |                 document.getElementById('recent-count').textContent = stats.recent_signups; | ||||||
|  |                 document.getElementById('sent-count').textContent = stats.newsletters_sent; | ||||||
|  |             } catch (error) { | ||||||
|  |                 console.error('Error loading stats:', error); | ||||||
|  |                 document.getElementById('active-count').textContent = 'Error'; | ||||||
|  |                 document.getElementById('recent-count').textContent = 'Error'; | ||||||
|  |                 document.getElementById('sent-count').textContent = 'Error'; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Rich text formatting functions | ||||||
|  |         function formatText(command) { | ||||||
|  |             const textarea = document.getElementById('body'); | ||||||
|  |             const start = textarea.selectionStart; | ||||||
|  |             const end = textarea.selectionEnd; | ||||||
|  |             const selectedText = textarea.value.substring(start, end); | ||||||
|  |              | ||||||
|  |             if (selectedText) { | ||||||
|  |                 let formattedText = ''; | ||||||
|  |                 switch (command) { | ||||||
|  |                     case 'bold': | ||||||
|  |                         formattedText = `<strong>${selectedText}</strong>`; | ||||||
|  |                         break; | ||||||
|  |                     case 'italic': | ||||||
|  |                         formattedText = `<em>${selectedText}</em>`; | ||||||
|  |                         break; | ||||||
|  |                     case 'underline': | ||||||
|  |                         formattedText = `<u>${selectedText}</u>`; | ||||||
|  |                         break; | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 textarea.value = textarea.value.substring(0, start) + formattedText + textarea.value.substring(end); | ||||||
|  |                 textarea.focus(); | ||||||
|  |                 textarea.setSelectionRange(start, start + formattedText.length); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         function insertLink() { | ||||||
|  |             const url = prompt('Enter the URL:'); | ||||||
|  |             const text = prompt('Enter the link text:') || url; | ||||||
|  |              | ||||||
|  |             if (url) { | ||||||
|  |                 const textarea = document.getElementById('body'); | ||||||
|  |                 const start = textarea.selectionStart; | ||||||
|  |                 const linkText = `<a href="${url}">${text}</a>`; | ||||||
|  |                  | ||||||
|  |                 textarea.value = textarea.value.substring(0, start) + linkText + textarea.value.substring(textarea.selectionEnd); | ||||||
|  |                 textarea.focus(); | ||||||
|  |                 textarea.setSelectionRange(start + linkText.length, start + linkText.length); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         function insertList() { | ||||||
|  |             const textarea = document.getElementById('body'); | ||||||
|  |             const start = textarea.selectionStart; | ||||||
|  |             const listText = `<ul>\n  <li>List item 1</li>\n  <li>List item 2</li>\n  <li>List item 3</li>\n</ul>`; | ||||||
|  |              | ||||||
|  |             textarea.value = textarea.value.substring(0, start) + listText + textarea.value.substring(textarea.selectionEnd); | ||||||
|  |             textarea.focus(); | ||||||
|  |             textarea.setSelectionRange(start + listText.length, start + listText.length); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Handle form submission | ||||||
|  |         document.getElementById('newsletter-form').addEventListener('submit', function(e) { | ||||||
|  |             const action = e.submitter.value; | ||||||
|  |              | ||||||
|  |             if (action === 'send') { | ||||||
|  |                 if (!confirm('Are you sure you want to send this newsletter to all subscribers? This action cannot be undone.')) { | ||||||
|  |                     e.preventDefault(); | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 // Show loading state | ||||||
|  |                 const sendBtn = document.getElementById('send-btn'); | ||||||
|  |                 const sendText = document.getElementById('send-text'); | ||||||
|  |                 const sendSpinner = document.getElementById('send-spinner'); | ||||||
|  |                  | ||||||
|  |                 sendBtn.disabled = true; | ||||||
|  |                 sendText.style.display = 'none'; | ||||||
|  |                 sendSpinner | ||||||
|  | @ -1,37 +0,0 @@ | ||||||
| <!DOCTYPE html> |  | ||||||
| <html lang="en"> |  | ||||||
| 
 |  | ||||||
| <head> |  | ||||||
|   <meta charset="UTF-8"> |  | ||||||
|   <meta name="viewport" content="width=device-width, initial-scale=1.0"> |  | ||||||
|   <title>Admin Center - Send Update</title> |  | ||||||
|   <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> |  | ||||||
| </head> |  | ||||||
| 
 |  | ||||||
| <body> |  | ||||||
|   <h1>Send Update Email</h1> |  | ||||||
|   <p> |  | ||||||
|     <a href="{{ url_for('index') }}">Back to Subscribers List</a> | |  | ||||||
|     <a href="{{ url_for('logout') }}">Logout</a> |  | ||||||
|   </p> |  | ||||||
|   {% with messages = get_flashed_messages() %} |  | ||||||
|     {% if messages %} |  | ||||||
|       {% for message in messages %} |  | ||||||
|         <div class="flash">{{ message }}</div> |  | ||||||
|       {% endfor %} |  | ||||||
|     {% endif %} |  | ||||||
|   {% endwith %} |  | ||||||
| 
 |  | ||||||
|   <form action="{{ url_for('send_update') }}" method="POST"> |  | ||||||
|     <label for="subject">Subject:</label> |  | ||||||
|     <input type="text" name="subject" required> |  | ||||||
| 
 |  | ||||||
|     <label for="body">Body (HTML allowed):</label> |  | ||||||
|     <textarea name="body" rows="10" required></textarea> |  | ||||||
| 
 |  | ||||||
|     <button type="submit">Send Update</button> |  | ||||||
|   </form> |  | ||||||
| 
 |  | ||||||
| </body> |  | ||||||
| 
 |  | ||||||
| </html> |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue