414 lines
		
	
	
		
			No EOL
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			414 lines
		
	
	
		
			No EOL
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| import os
 | |
| import logging
 | |
| import smtplib
 | |
| from email.mime.text import MIMEText
 | |
| from email.mime.multipart import MIMEMultipart
 | |
| import concurrent.futures
 | |
| from threading import Lock
 | |
| from flask import (
 | |
|     Flask,
 | |
|     render_template,
 | |
|     request,
 | |
|     redirect,
 | |
|     url_for,
 | |
|     flash,
 | |
|     session,
 | |
|     jsonify,
 | |
|     abort
 | |
| )
 | |
| from dotenv import load_dotenv
 | |
| from werkzeug.security import check_password_hash
 | |
| from functools import wraps
 | |
| import re
 | |
| from database import (
 | |
|     get_db_connection, init_db, get_all_emails, get_admin, create_default_admin,
 | |
|     get_subscriber_stats, add_email, remove_email, save_newsletter,
 | |
|     update_newsletter_stats, log_email_delivery, get_recent_newsletters
 | |
| )
 | |
| 
 | |
| load_dotenv()
 | |
| app = Flask(__name__)
 | |
| app.secret_key = os.getenv("SECRET_KEY")
 | |
| base_url = os.getenv("BASE_URL")
 | |
| 
 | |
| # SMTP settings
 | |
| SMTP_SERVER = os.getenv("SMTP_SERVER")
 | |
| SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
 | |
| SMTP_USER = os.getenv("SMTP_USER")
 | |
| SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
 | |
| SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER)
 | |
| SENDER_NAME = os.getenv("SENDER_NAME", "Newsletter Admin")
 | |
| 
 | |
| # Email sending configuration
 | |
| MAX_EMAIL_WORKERS = 5
 | |
| email_send_lock = Lock()
 | |
| 
 | |
| # Logging setup
 | |
| logging.basicConfig(
 | |
|     level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
 | |
| )
 | |
| logger = logging.getLogger(__name__)
 | |
| 
 | |
| # Initialize the database and create default admin user
 | |
| init_db()
 | |
| create_default_admin()
 | |
| 
 | |
| # Security decorators
 | |
| def login_required(f):
 | |
|     @wraps(f)
 | |
|     def decorated_function(*args, **kwargs):
 | |
|         if "username" not in session:
 | |
|             if request.is_json:
 | |
|                 return jsonify({"error": "Authentication required"}), 401
 | |
|             flash("Please log in to access this page.", "warning")
 | |
|             return redirect(url_for("login"))
 | |
|         return f(*args, **kwargs)
 | |
|     return decorated_function
 | |
| 
 | |
| def validate_email(email):
 | |
|     """Validate email format."""
 | |
|     pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
 | |
|     return re.match(pattern, email) is not None
 | |
| 
 | |
| def send_single_email(email_data):
 | |
|     """Send a single email (for thread pool execution)."""
 | |
|     email, subject, body, newsletter_id = email_data
 | |
|     try:
 | |
|         server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=15)
 | |
|         server.login(SMTP_USER, SMTP_PASSWORD)
 | |
| 
 | |
|         # Create message
 | |
|         msg = MIMEMultipart('alternative')
 | |
|         msg['Subject'] = subject
 | |
|         msg['From'] = f"{SENDER_NAME} <{SENDER_EMAIL}>"
 | |
|         msg['To'] = email
 | |
| 
 | |
|         # Create unsubscribe link
 | |
|         unsub_link = f"https://{base_url}/unsubscribe?email={email}"
 | |
|         
 | |
|         # HTML body with unsubscribe link
 | |
|         html_body = f"""
 | |
|         <html>
 | |
|         <body>
 | |
|         {body}
 | |
|         <hr style="margin-top: 40px; border: none; border-top: 1px solid #eee;">
 | |
|         <p style="font-size: 12px; color: #888; text-align: center;">
 | |
|         If you wish to unsubscribe, please <a href="{unsub_link}" style="color: #888;">click here</a>
 | |
|         </p>
 | |
|         </body>
 | |
|         </html>
 | |
|         """
 | |
| 
 | |
|         # Attach HTML part
 | |
|         html_part = MIMEText(html_body, 'html')
 | |
|         msg.attach(html_part)
 | |
| 
 | |
|         server.sendmail(SENDER_EMAIL, email, msg.as_string())
 | |
|         server.quit()
 | |
|         
 | |
|         # Log successful delivery
 | |
|         if newsletter_id:
 | |
|             log_email_delivery(newsletter_id, email, 'sent')
 | |
|         
 | |
|         logger.info(f"Email sent successfully to: {email}")
 | |
|         return True, email, None
 | |
|     except Exception as e:
 | |
|         error_msg = str(e)
 | |
|         logger.error(f"Failed to send email to {email}: {error_msg}")
 | |
|         
 | |
|         # Log failed delivery
 | |
|         if newsletter_id:
 | |
|             log_email_delivery(newsletter_id, email, 'failed', error_msg)
 | |
|         
 | |
|         return False, email, error_msg
 | |
| 
 | |
| def send_newsletter_batch(subject, body, email_list, newsletter_id=None):
 | |
|     """Send newsletter to multiple recipients using thread pool."""
 | |
|     success_count = 0
 | |
|     failure_count = 0
 | |
|     failed_emails = []
 | |
|     
 | |
|     # Prepare email data for thread pool
 | |
|     email_data_list = [(email, subject, body, newsletter_id) for email in email_list]
 | |
|     
 | |
|     with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_EMAIL_WORKERS) as executor:
 | |
|         future_to_email = {
 | |
|             executor.submit(send_single_email, email_data): email_data[0] 
 | |
|             for email_data in email_data_list
 | |
|         }
 | |
|         
 | |
|         for future in concurrent.futures.as_completed(future_to_email):
 | |
|             success, email, error = future.result()
 | |
|             if success:
 | |
|                 success_count += 1
 | |
|             else:
 | |
|                 failure_count += 1
 | |
|                 failed_emails.append({'email': email, 'error': error})
 | |
|     
 | |
|     return success_count, failure_count, failed_emails
 | |
| 
 | |
| @app.route("/")
 | |
| @login_required
 | |
| def index():
 | |
|     """Dashboard with subscriber statistics and recent activity."""
 | |
|     page = request.args.get('page', 1, type=int)
 | |
|     search = request.args.get('search', '')
 | |
|     per_page = 25
 | |
|     
 | |
|     stats = get_subscriber_stats()
 | |
|     subscribers_data = get_all_emails(page=page, per_page=per_page, search=search)
 | |
|     recent_newsletters = get_recent_newsletters(limit=5)
 | |
|     
 | |
|     return render_template(
 | |
|         "admin_index.html", 
 | |
|         stats=stats,
 | |
|         subscribers=subscribers_data['subscribers'],
 | |
|         pagination={
 | |
|             'page': subscribers_data['page'],
 | |
|             'per_page': subscribers_data['per_page'],
 | |
|             'total_pages': subscribers_data['total_pages'],
 | |
|             'total_count': subscribers_data['total_count']
 | |
|         },
 | |
|         search=search,
 | |
|         recent_newsletters=recent_newsletters
 | |
|     )
 | |
| 
 | |
| @app.route("/subscribers")
 | |
| @login_required
 | |
| def subscribers():
 | |
|     """Detailed subscriber management page."""
 | |
|     page = request.args.get('page', 1, type=int)
 | |
|     search = request.args.get('search', '')
 | |
|     per_page = 50
 | |
|     
 | |
|     subscribers_data = get_all_emails(page=page, per_page=per_page, search=search)
 | |
|     
 | |
|     return render_template(
 | |
|         "subscribers.html",
 | |
|         subscribers=subscribers_data['subscribers'],
 | |
|         pagination={
 | |
|             'page': subscribers_data['page'],
 | |
|             'per_page': subscribers_data['per_page'],
 | |
|             'total_pages': subscribers_data['total_pages'],
 | |
|             'total_count': subscribers_data['total_count']
 | |
|         },
 | |
|         search=search
 | |
|     )
 | |
| 
 | |
| @app.route("/add_subscriber", methods=["POST"])
 | |
| @login_required
 | |
| def add_subscriber():
 | |
|     """Add a new subscriber via AJAX."""
 | |
|     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"])
 | |
| def login():
 | |
|     if request.method == "POST":
 | |
|         username = request.form.get("username", "").strip()
 | |
|         password = request.form.get("password", "")
 | |
|         
 | |
|         if not username or not password:
 | |
|             flash("Username and password are required", "error")
 | |
|             return redirect(url_for("login"))
 | |
|         
 | |
|         admin = get_admin(username)
 | |
|         if admin and 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["admin_id"] = admin[0]
 | |
|             flash("Logged in successfully!", "success")
 | |
|             
 | |
|             # Redirect to intended page or dashboard
 | |
|             next_page = request.args.get('next')
 | |
|             if next_page:
 | |
|                 return redirect(next_page)
 | |
|             return redirect(url_for("index"))
 | |
|         else:
 | |
|             flash("Invalid username or password", "error")
 | |
|             return redirect(url_for("login"))
 | |
|     
 | |
|     return render_template("login.html")
 | |
| 
 | |
| @app.route("/logout")
 | |
| def logout():
 | |
|     session.clear()
 | |
|     flash("You have been logged out successfully", "info")
 | |
|     return redirect(url_for("login"))
 | |
| 
 | |
| # Public unsubscribe endpoint
 | |
| @app.route("/unsubscribe")
 | |
| def unsubscribe():
 | |
|     """Public unsubscribe endpoint."""
 | |
|     email = request.args.get('email', '').strip().lower()
 | |
|     
 | |
|     if not email or not validate_email(email):
 | |
|         return render_template("unsubscribe.html", error="Invalid email address")
 | |
|     
 | |
|     success = remove_email(email)
 | |
|     
 | |
|     if success:
 | |
|         return render_template("unsubscribe.html", success=True, email=email)
 | |
|     else:
 | |
|         return render_template("unsubscribe.html", error="Email not found or already unsubscribed")
 | |
| 
 | |
| # API endpoints for AJAX requests
 | |
| @app.route("/api/stats")
 | |
| @login_required
 | |
| def api_stats():
 | |
|     """API endpoint for dashboard statistics."""
 | |
|     stats = get_subscriber_stats()
 | |
|     return jsonify(stats)
 | |
| 
 | |
| # Error handlers
 | |
| @app.errorhandler(404)
 | |
| def not_found(error):
 | |
|     return render_template("error.html", error="Page not found", code=404), 404
 | |
| 
 | |
| @app.errorhandler(500)
 | |
| def internal_error(error):
 | |
|     logger.error(f"Internal error: {error}")
 | |
|     return render_template("error.html", error="Internal server error", code=500), 500
 | |
| 
 | |
| # Context processors
 | |
| @app.context_processor
 | |
| def inject_user():
 | |
|     return dict(current_user=session.get('username'))
 | |
| 
 | |
| if __name__ == "__main__":
 | |
|     app.run(host="0.0.0.0", port=5001, debug=True) | 
