Initial commit.
This commit is contained in:
16
devbox-back/README.md
Normal file
16
devbox-back/README.md
Normal file
@ -0,0 +1,16 @@
|
||||
# DevBox Email Collection API
|
||||
|
||||
A simple and efficient email collection backend for the DevBox landing page.
|
||||
|
||||
## Features
|
||||
|
||||
- 📧 Email collection and validation
|
||||
- 📊 Statistics and analytics
|
||||
- 💾 SQLite database storage
|
||||
- 🔍 Search and export functionality
|
||||
- 🚀 FastAPI with automatic documentation
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
./run.sh
|
541
devbox-back/database_manager.py
Executable file
541
devbox-back/database_manager.py
Executable file
@ -0,0 +1,541 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DevBox Email Database Management Utilities
|
||||
|
||||
This script provides database management utilities for the DevBox email collection system.
|
||||
Offers tools for database maintenance, backups, and data analysis.
|
||||
|
||||
Usage:
|
||||
python database_manager.py --help
|
||||
python database_manager.py --export
|
||||
python database_manager.py --backup
|
||||
python database_manager.py --stats
|
||||
python database_manager.py --cleanup-duplicates
|
||||
python database_manager.py --validate-emails
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import csv
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
import logging
|
||||
import shutil
|
||||
import os
|
||||
import re
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DevBoxEmailManager:
|
||||
"""
|
||||
Database manager for DevBox email subscriptions
|
||||
"""
|
||||
|
||||
def __init__(self, db_file: str = "devbox_emails.db"):
|
||||
self.db_file = db_file
|
||||
self.ensure_database_exists()
|
||||
|
||||
def ensure_database_exists(self):
|
||||
"""Ensure database exists and is properly initialized"""
|
||||
if not os.path.exists(self.db_file):
|
||||
logger.warning(f"Database {self.db_file} not found. Creating new database...")
|
||||
self.init_database()
|
||||
else:
|
||||
logger.info(f"Using existing database: {self.db_file}")
|
||||
# Verify table structure
|
||||
self._verify_schema()
|
||||
|
||||
def get_connection(self):
|
||||
"""Get database connection with error handling"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_file)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to database: {e}")
|
||||
raise
|
||||
|
||||
def _verify_schema(self):
|
||||
"""Verify database schema is correct"""
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
# Check if table exists and has correct columns
|
||||
cursor = conn.execute("PRAGMA table_info(email_signups)")
|
||||
columns = {row[1] for row in cursor.fetchall()}
|
||||
|
||||
expected_columns = {'id', 'email', 'source', 'created_at', 'user_agent', 'ip_address'}
|
||||
if not expected_columns.issubset(columns):
|
||||
logger.warning("Database schema incomplete. Updating...")
|
||||
self._update_schema(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _update_schema(self, conn):
|
||||
"""Update database schema if needed"""
|
||||
try:
|
||||
# Add missing columns if they don't exist
|
||||
try:
|
||||
conn.execute("ALTER TABLE email_signups ADD COLUMN user_agent TEXT")
|
||||
logger.info("Added user_agent column")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
|
||||
try:
|
||||
conn.execute("ALTER TABLE email_signups ADD COLUMN ip_address TEXT")
|
||||
logger.info("Added ip_address column")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update schema: {e}")
|
||||
|
||||
def init_database(self):
|
||||
"""Initialize database with required tables"""
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS email_signups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
source TEXT DEFAULT 'devbox-landing',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
user_agent TEXT,
|
||||
ip_address TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for better performance
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_email ON email_signups(email)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_created_at ON email_signups(created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_source ON email_signups(source)")
|
||||
|
||||
conn.commit()
|
||||
logger.info("Database initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def export_to_csv(self, filename: Optional[str] = None, source_filter: Optional[str] = None) -> str:
|
||||
"""Export emails to CSV file"""
|
||||
if filename is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
source_suffix = f"_{source_filter}" if source_filter else ""
|
||||
filename = f"devbox_emails{source_suffix}_{timestamp}.csv"
|
||||
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
query = """
|
||||
SELECT id, email, source, created_at, user_agent, ip_address
|
||||
FROM email_signups
|
||||
"""
|
||||
params = []
|
||||
|
||||
if source_filter:
|
||||
query += " WHERE source = ?"
|
||||
params.append(source_filter)
|
||||
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
emails = conn.execute(query, params).fetchall()
|
||||
|
||||
with open(filename, 'w', newline='', encoding='utf-8') as csvfile:
|
||||
writer = csv.writer(csvfile)
|
||||
writer.writerow(['ID', 'Email', 'Source', 'Created At', 'User Agent', 'IP Address'])
|
||||
|
||||
for email in emails:
|
||||
writer.writerow([
|
||||
email['id'],
|
||||
email['email'],
|
||||
email['source'] or 'devbox-landing',
|
||||
email['created_at'],
|
||||
email['user_agent'] or '',
|
||||
email['ip_address'] or ''
|
||||
])
|
||||
|
||||
logger.info(f"Exported {len(emails)} emails to {filename}")
|
||||
print(f"✅ Exported {len(emails)} emails to {filename}")
|
||||
return filename
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Export failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_detailed_stats(self) -> Dict:
|
||||
"""Get comprehensive statistics"""
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
# Basic stats
|
||||
basic_stats = conn.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total_signups,
|
||||
COUNT(CASE WHEN DATE(created_at) = DATE('now') THEN 1 END) as today_signups,
|
||||
COUNT(CASE WHEN DATE(created_at) >= DATE('now', '-7 days') THEN 1 END) as week_signups,
|
||||
COUNT(CASE WHEN DATE(created_at) >= DATE('now', '-30 days') THEN 1 END) as month_signups,
|
||||
MIN(created_at) as first_signup,
|
||||
MAX(created_at) as latest_signup
|
||||
FROM email_signups
|
||||
""").fetchone()
|
||||
|
||||
if not basic_stats or basic_stats['total_signups'] == 0:
|
||||
return {
|
||||
'message': 'No email signups found in database',
|
||||
'basic_stats': {
|
||||
'total_signups': 0,
|
||||
'today_signups': 0,
|
||||
'week_signups': 0,
|
||||
'month_signups': 0,
|
||||
'first_signup': None,
|
||||
'latest_signup': None
|
||||
},
|
||||
'sources': {},
|
||||
'daily_signups': [],
|
||||
'top_domains': []
|
||||
}
|
||||
|
||||
# Source breakdown
|
||||
sources = conn.execute("""
|
||||
SELECT COALESCE(source, 'devbox-landing') as source, COUNT(*) as count
|
||||
FROM email_signups
|
||||
GROUP BY COALESCE(source, 'devbox-landing')
|
||||
ORDER BY count DESC
|
||||
""").fetchall()
|
||||
|
||||
# Daily signups for last 30 days
|
||||
daily_signups = conn.execute("""
|
||||
SELECT
|
||||
DATE(created_at) as signup_date,
|
||||
COUNT(*) as daily_count
|
||||
FROM email_signups
|
||||
WHERE DATE(created_at) >= DATE('now', '-30 days')
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY signup_date DESC
|
||||
""").fetchall()
|
||||
|
||||
# Top domains
|
||||
top_domains = conn.execute("""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN INSTR(email, '@') > 0
|
||||
THEN SUBSTR(email, INSTR(email, '@') + 1)
|
||||
ELSE 'invalid'
|
||||
END as domain,
|
||||
COUNT(*) as count
|
||||
FROM email_signups
|
||||
GROUP BY domain
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
""").fetchall()
|
||||
|
||||
return {
|
||||
'basic_stats': dict(basic_stats),
|
||||
'sources': {row['source']: row['count'] for row in sources},
|
||||
'daily_signups': [dict(row) for row in daily_signups],
|
||||
'top_domains': [dict(row) for row in top_domains]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get stats: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def cleanup_duplicates(self) -> int:
|
||||
"""Remove duplicate emails (keep the earliest)"""
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
# Find duplicates
|
||||
duplicates = conn.execute("""
|
||||
SELECT email, COUNT(*) as count, MIN(id) as keep_id
|
||||
FROM email_signups
|
||||
GROUP BY LOWER(email)
|
||||
HAVING COUNT(*) > 1
|
||||
""").fetchall()
|
||||
|
||||
if not duplicates:
|
||||
print("✅ No duplicates found")
|
||||
return 0
|
||||
|
||||
removed_count = 0
|
||||
for dup in duplicates:
|
||||
# Remove all but the earliest (smallest ID)
|
||||
result = conn.execute("""
|
||||
DELETE FROM email_signups
|
||||
WHERE LOWER(email) = LOWER(?) AND id != ?
|
||||
""", (dup['email'], dup['keep_id']))
|
||||
removed_count += result.rowcount
|
||||
logger.info(f"Removed {result.rowcount} duplicate(s) for {dup['email']}")
|
||||
print(f"🧹 Removed {result.rowcount} duplicate(s) for {dup['email']}")
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"Cleanup complete. Removed {removed_count} duplicate records.")
|
||||
print(f"✅ Cleanup complete. Removed {removed_count} duplicate records.")
|
||||
return removed_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cleanup failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def backup_database(self, backup_file: Optional[str] = None) -> str:
|
||||
"""Create a backup of the database"""
|
||||
if backup_file is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_file = f"devbox_emails_backup_{timestamp}.db"
|
||||
|
||||
try:
|
||||
if not os.path.exists(self.db_file):
|
||||
raise FileNotFoundError(f"Source database {self.db_file} does not exist")
|
||||
|
||||
shutil.copy2(self.db_file, backup_file)
|
||||
file_size = os.path.getsize(backup_file)
|
||||
logger.info(f"Database backed up to {backup_file} ({file_size:,} bytes)")
|
||||
print(f"✅ Database backed up to {backup_file} ({file_size:,} bytes)")
|
||||
return backup_file
|
||||
except Exception as e:
|
||||
logger.error(f"Backup failed: {e}")
|
||||
raise
|
||||
|
||||
def analyze_growth(self, days: int = 30) -> Dict:
|
||||
"""Analyze signup growth trends"""
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
growth_data = conn.execute("""
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as signups,
|
||||
SUM(COUNT(*)) OVER (ORDER BY DATE(created_at)) as cumulative
|
||||
FROM email_signups
|
||||
WHERE DATE(created_at) >= DATE('now', '-' || ? || ' days')
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date
|
||||
""", (days,)).fetchall()
|
||||
|
||||
if not growth_data:
|
||||
return {
|
||||
'message': f'No data available for the last {days} days',
|
||||
'period_days': days,
|
||||
'total_signups_in_period': 0,
|
||||
'growth_rate_percent': 0,
|
||||
'daily_data': [],
|
||||
'average_daily_signups': 0
|
||||
}
|
||||
|
||||
# Calculate growth rate
|
||||
total_signups = growth_data[-1]['cumulative'] if growth_data else 0
|
||||
first_day_cumulative = growth_data[0]['cumulative'] - growth_data[0]['signups'] if growth_data else 0
|
||||
signups_in_period = total_signups - first_day_cumulative
|
||||
|
||||
growth_rate = 0
|
||||
if first_day_cumulative > 0:
|
||||
growth_rate = (signups_in_period / first_day_cumulative) * 100
|
||||
|
||||
return {
|
||||
'period_days': days,
|
||||
'total_signups_in_period': signups_in_period,
|
||||
'growth_rate_percent': round(growth_rate, 2),
|
||||
'daily_data': [dict(row) for row in growth_data],
|
||||
'average_daily_signups': round(signups_in_period / days, 2) if days > 0 else 0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Growth analysis failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def validate_emails(self) -> Dict:
|
||||
"""Validate email formats in the database"""
|
||||
email_pattern = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||||
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
all_emails = conn.execute("SELECT id, email FROM email_signups").fetchall()
|
||||
|
||||
if not all_emails:
|
||||
return {
|
||||
'total_emails': 0,
|
||||
'valid_emails': 0,
|
||||
'invalid_emails': 0,
|
||||
'invalid_list': []
|
||||
}
|
||||
|
||||
valid_emails = []
|
||||
invalid_emails = []
|
||||
|
||||
for row in all_emails:
|
||||
email = row['email'].strip() if row['email'] else ''
|
||||
if email and email_pattern.match(email):
|
||||
valid_emails.append(email)
|
||||
else:
|
||||
invalid_emails.append({'id': row['id'], 'email': email})
|
||||
|
||||
return {
|
||||
'total_emails': len(all_emails),
|
||||
'valid_emails': len(valid_emails),
|
||||
'invalid_emails': len(invalid_emails),
|
||||
'invalid_list': invalid_emails
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Email validation failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_email_count(self) -> int:
|
||||
"""Get total number of subscribed emails"""
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
result = conn.execute("SELECT COUNT(*) as count FROM email_signups").fetchone()
|
||||
return result['count'] if result else 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get email count: {e}")
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def print_stats_table(stats: Dict):
|
||||
"""Print statistics in a nice table format"""
|
||||
print("\n📊 DevBox Email Statistics")
|
||||
print("=" * 50)
|
||||
|
||||
basic = stats.get('basic_stats', {})
|
||||
print(f"Total Signups: {basic.get('total_signups', 0):,}")
|
||||
print(f"Today: {basic.get('today_signups', 0):,}")
|
||||
print(f"This Week: {basic.get('week_signups', 0):,}")
|
||||
print(f"This Month: {basic.get('month_signups', 0):,}")
|
||||
print(f"First Signup: {basic.get('first_signup', 'N/A')}")
|
||||
print(f"Latest Signup: {basic.get('latest_signup', 'N/A')}")
|
||||
|
||||
sources = stats.get('sources', {})
|
||||
if sources:
|
||||
print(f"\n📈 Sources:")
|
||||
for source, count in sources.items():
|
||||
print(f" {source}: {count:,}")
|
||||
|
||||
domains = stats.get('top_domains', [])
|
||||
if domains:
|
||||
print(f"\n🌐 Top Domains:")
|
||||
for domain in domains[:5]:
|
||||
print(f" {domain['domain']}: {domain['count']:,}")
|
||||
|
||||
def main():
|
||||
"""Command line interface for database management"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='DevBox Email Database Manager',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python database_manager.py --stats
|
||||
python database_manager.py --export
|
||||
python database_manager.py --export --source-filter devbox-landing
|
||||
python database_manager.py --backup
|
||||
python database_manager.py --cleanup-duplicates
|
||||
python database_manager.py --validate-emails
|
||||
python database_manager.py --analyze-growth 14
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument('--export', action='store_true',
|
||||
help='Export emails to CSV')
|
||||
parser.add_argument('--backup', action='store_true',
|
||||
help='Create database backup')
|
||||
parser.add_argument('--stats', action='store_true',
|
||||
help='Show detailed statistics')
|
||||
parser.add_argument('--cleanup-duplicates', action='store_true',
|
||||
help='Remove duplicate emails')
|
||||
parser.add_argument('--analyze-growth', type=int, metavar='DAYS',
|
||||
help='Analyze growth over N days')
|
||||
parser.add_argument('--validate-emails', action='store_true',
|
||||
help='Validate email formats')
|
||||
parser.add_argument('--source-filter', type=str,
|
||||
help='Filter by source (for export)')
|
||||
parser.add_argument('--db-file', type=str, default='devbox_emails.db',
|
||||
help='Database file path')
|
||||
parser.add_argument('--count', action='store_true',
|
||||
help='Show total email count')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# If no arguments provided, show help
|
||||
if len(sys.argv) == 1:
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
try:
|
||||
manager = DevBoxEmailManager(args.db_file)
|
||||
|
||||
if args.count:
|
||||
count = manager.get_email_count()
|
||||
print(f"📧 Total emails in database: {count:,}")
|
||||
|
||||
if args.export:
|
||||
filename = manager.export_to_csv(source_filter=args.source_filter)
|
||||
# filename is already printed by the method
|
||||
|
||||
if args.backup:
|
||||
backup_file = manager.backup_database()
|
||||
# backup_file is already printed by the method
|
||||
|
||||
if args.stats:
|
||||
stats = manager.get_detailed_stats()
|
||||
if 'message' in stats:
|
||||
print(f"ℹ️ {stats['message']}")
|
||||
else:
|
||||
print_stats_table(stats)
|
||||
|
||||
if args.cleanup_duplicates:
|
||||
removed = manager.cleanup_duplicates()
|
||||
# results are already printed by the method
|
||||
|
||||
if args.analyze_growth:
|
||||
growth = manager.analyze_growth(args.analyze_growth)
|
||||
print(f"\n📈 Growth Analysis ({args.analyze_growth} days):")
|
||||
print("=" * 40)
|
||||
if 'message' in growth:
|
||||
print(f"ℹ️ {growth['message']}")
|
||||
else:
|
||||
print(f"Total signups in period: {growth['total_signups_in_period']:,}")
|
||||
print(f"Growth rate: {growth['growth_rate_percent']}%")
|
||||
print(f"Average daily signups: {growth['average_daily_signups']}")
|
||||
|
||||
if args.validate_emails:
|
||||
validation = manager.validate_emails()
|
||||
print(f"\n✉️ Email Validation Results:")
|
||||
print("=" * 35)
|
||||
print(f"Total emails: {validation['total_emails']:,}")
|
||||
print(f"Valid emails: {validation['valid_emails']:,}")
|
||||
print(f"Invalid emails: {validation['invalid_emails']:,}")
|
||||
|
||||
if validation['invalid_emails'] > 0:
|
||||
print(f"\n⚠️ Invalid emails found:")
|
||||
for invalid in validation['invalid_list'][:10]: # Show first 10
|
||||
print(f" ID {invalid['id']}: {invalid['email']}")
|
||||
if len(validation['invalid_list']) > 10:
|
||||
print(f" ... and {len(validation['invalid_list']) - 10} more")
|
||||
|
||||
return 0
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 Operation cancelled by user")
|
||||
return 1
|
||||
except Exception as e:
|
||||
logger.error(f"Operation failed: {e}")
|
||||
print(f"❌ Error: {e}")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
465
devbox-back/main.py
Normal file
465
devbox-back/main.py
Normal file
@ -0,0 +1,465 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from contextlib import asynccontextmanager
|
||||
import sqlite3
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
import uvicorn
|
||||
import os
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Database configuration
|
||||
DATABASE_FILE = "devbox_emails.db"
|
||||
|
||||
def get_db_connection():
|
||||
"""Create and return a database connection"""
|
||||
conn = sqlite3.connect(DATABASE_FILE)
|
||||
conn.row_factory = sqlite3.Row # This allows dict-like access to rows
|
||||
return conn
|
||||
|
||||
def init_database():
|
||||
"""Initialize the database with required tables"""
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS email_signups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
source TEXT DEFAULT 'devbox-landing',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
user_agent TEXT,
|
||||
ip_address TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for better performance
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_email ON email_signups(email)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_created_at ON email_signups(created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_source ON email_signups(source)")
|
||||
|
||||
conn.commit()
|
||||
logger.info("Database initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing database: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Handle application startup and shutdown events"""
|
||||
# Startup
|
||||
logger.info("DevBox Email Collection API starting up...")
|
||||
init_database()
|
||||
logger.info("DevBox Email Collection API started successfully")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("DevBox Email Collection API shutting down...")
|
||||
|
||||
# Create FastAPI app with lifespan
|
||||
app = FastAPI(
|
||||
title="DevBox Email Collection API",
|
||||
description="Backend API for collecting email signups for DevBox landing page",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS middleware to allow requests from your frontend
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # In production, specify your domain
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Pydantic models
|
||||
class EmailSignup(BaseModel):
|
||||
email: EmailStr
|
||||
source: Optional[str] = "devbox-landing"
|
||||
|
||||
class EmailSignupResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
email: str
|
||||
id: Optional[int] = None
|
||||
|
||||
class EmailListResponse(BaseModel):
|
||||
total_count: int
|
||||
emails: List[dict]
|
||||
|
||||
class StatsResponse(BaseModel):
|
||||
total_signups: int
|
||||
today_signups: int
|
||||
week_signups: int
|
||||
month_signups: int
|
||||
first_signup: Optional[str]
|
||||
latest_signup: Optional[str]
|
||||
sources: dict
|
||||
|
||||
class UnsubscribeResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
# Helper function to get client IP
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract client IP address from request"""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
# API endpoints
|
||||
@app.post("/api/subscribe", response_model=EmailSignupResponse)
|
||||
async def subscribe_email(signup: EmailSignup, request: Request):
|
||||
"""
|
||||
Subscribe an email address to the DevBox mailing list
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
# Check if email already exists
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM email_signups WHERE email = ?",
|
||||
(signup.email,)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
return EmailSignupResponse(
|
||||
success=False,
|
||||
message="Email already subscribed",
|
||||
email=signup.email
|
||||
)
|
||||
|
||||
# Get client information
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
ip_address = get_client_ip(request)
|
||||
|
||||
# Insert new email
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO email_signups (email, source, created_at, user_agent, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(signup.email, signup.source, datetime.utcnow(), user_agent, ip_address)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"New email subscription: {signup.email} from {ip_address}")
|
||||
|
||||
return EmailSignupResponse(
|
||||
success=True,
|
||||
message="Successfully subscribed to DevBox updates!",
|
||||
email=signup.email,
|
||||
id=cursor.lastrowid
|
||||
)
|
||||
|
||||
except sqlite3.IntegrityError:
|
||||
return EmailSignupResponse(
|
||||
success=False,
|
||||
message="Email already subscribed",
|
||||
email=signup.email
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error subscribing email {signup.email}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.get("/api/emails", response_model=EmailListResponse)
|
||||
async def get_emails(limit: int = 100, offset: int = 0, source: Optional[str] = None):
|
||||
"""
|
||||
Get list of subscribed emails (for admin purposes)
|
||||
Note: In production, add authentication/authorization
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
# Build query with optional source filter
|
||||
base_query = "SELECT COUNT(*) as count FROM email_signups"
|
||||
emails_query = """
|
||||
SELECT id, email, source, created_at, ip_address
|
||||
FROM email_signups
|
||||
"""
|
||||
|
||||
params = []
|
||||
if source:
|
||||
base_query += " WHERE source = ?"
|
||||
emails_query += " WHERE source = ?"
|
||||
params.append(source)
|
||||
|
||||
# Get total count
|
||||
total_count = conn.execute(base_query, params).fetchone()["count"]
|
||||
|
||||
# Get emails with pagination
|
||||
emails_query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
emails = conn.execute(emails_query, params).fetchall()
|
||||
|
||||
# Convert to list of dicts
|
||||
email_list = [dict(email) for email in emails]
|
||||
|
||||
return EmailListResponse(
|
||||
total_count=total_count,
|
||||
emails=email_list
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching emails: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.get("/api/stats", response_model=StatsResponse)
|
||||
async def get_stats():
|
||||
"""
|
||||
Get detailed statistics about email signups
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
# Get basic stats
|
||||
stats = conn.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total_signups,
|
||||
COUNT(CASE WHEN DATE(created_at) = DATE('now') THEN 1 END) as today_signups,
|
||||
COUNT(CASE WHEN DATE(created_at) >= DATE('now', '-7 days') THEN 1 END) as week_signups,
|
||||
COUNT(CASE WHEN DATE(created_at) >= DATE('now', '-30 days') THEN 1 END) as month_signups,
|
||||
MIN(created_at) as first_signup,
|
||||
MAX(created_at) as latest_signup
|
||||
FROM email_signups
|
||||
""").fetchone()
|
||||
|
||||
# Get source breakdown
|
||||
sources = conn.execute("""
|
||||
SELECT source, COUNT(*) as count
|
||||
FROM email_signups
|
||||
GROUP BY source
|
||||
ORDER BY count DESC
|
||||
""").fetchall()
|
||||
|
||||
sources_dict = {row["source"]: row["count"] for row in sources}
|
||||
|
||||
return StatsResponse(
|
||||
total_signups=stats["total_signups"],
|
||||
today_signups=stats["today_signups"],
|
||||
week_signups=stats["week_signups"],
|
||||
month_signups=stats["month_signups"],
|
||||
first_signup=stats["first_signup"],
|
||||
latest_signup=stats["latest_signup"],
|
||||
sources=sources_dict
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching stats: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.delete("/api/unsubscribe/{email}", response_model=UnsubscribeResponse)
|
||||
async def unsubscribe_email(email: str):
|
||||
"""
|
||||
Remove an email from the subscription list
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
cursor = conn.execute("DELETE FROM email_signups WHERE email = ?", (email,))
|
||||
conn.commit()
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Email not found")
|
||||
|
||||
logger.info(f"Email unsubscribed: {email}")
|
||||
|
||||
return UnsubscribeResponse(
|
||||
success=True,
|
||||
message=f"Email {email} successfully unsubscribed"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error unsubscribing email {email}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.get("/api/export")
|
||||
async def export_emails(source: Optional[str] = None):
|
||||
"""
|
||||
Export emails to CSV format
|
||||
Note: In production, add authentication/authorization
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
query = """
|
||||
SELECT email, source, created_at, ip_address
|
||||
FROM email_signups
|
||||
"""
|
||||
params = []
|
||||
|
||||
if source:
|
||||
query += " WHERE source = ?"
|
||||
params.append(source)
|
||||
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
emails = conn.execute(query, params).fetchall()
|
||||
|
||||
# Convert to CSV format
|
||||
import io
|
||||
import csv
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Write header
|
||||
writer.writerow(['Email', 'Source', 'Created At', 'IP Address'])
|
||||
|
||||
# Write data
|
||||
for email in emails:
|
||||
writer.writerow([
|
||||
email['email'],
|
||||
email['source'],
|
||||
email['created_at'],
|
||||
email['ip_address']
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
# Create filename with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"devbox_emails_{timestamp}.csv"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(output.getvalue().encode('utf-8')),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting emails: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.get("/api/search")
|
||||
async def search_emails(q: str, limit: int = 50):
|
||||
"""
|
||||
Search emails by email address or domain
|
||||
"""
|
||||
if len(q) < 2:
|
||||
raise HTTPException(status_code=400, detail="Search query must be at least 2 characters")
|
||||
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
emails = conn.execute("""
|
||||
SELECT id, email, source, created_at
|
||||
FROM email_signups
|
||||
WHERE email LIKE ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""", (f"%{q}%", limit)).fetchall()
|
||||
|
||||
return {
|
||||
"query": q,
|
||||
"count": len(emails),
|
||||
"emails": [dict(email) for email in emails]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching emails: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""
|
||||
Root endpoint with API information
|
||||
"""
|
||||
return {
|
||||
"message": "DevBox Email Collection API",
|
||||
"version": "1.0.0",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"endpoints": {
|
||||
"subscribe": "POST /api/subscribe",
|
||||
"list_emails": "GET /api/emails",
|
||||
"stats": "GET /api/stats",
|
||||
"search": "GET /api/search?q=query",
|
||||
"export": "GET /api/export",
|
||||
"unsubscribe": "DELETE /api/unsubscribe/{email}",
|
||||
"health": "GET /health",
|
||||
"docs": "GET /docs"
|
||||
}
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
# Test database connection
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
db_status = "healthy"
|
||||
except Exception as e:
|
||||
db_status = f"unhealthy: {str(e)}"
|
||||
logger.error(f"Database health check failed: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"status": "healthy" if db_status == "healthy" else "unhealthy",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"database": db_status,
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
# Custom exception handler
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request: Request, exc):
|
||||
return {
|
||||
"error": "Not Found",
|
||||
"message": "The requested endpoint was not found",
|
||||
"available_endpoints": [
|
||||
"/",
|
||||
"/health",
|
||||
"/docs",
|
||||
"/api/subscribe",
|
||||
"/api/emails",
|
||||
"/api/stats",
|
||||
"/api/search",
|
||||
"/api/export"
|
||||
]
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Check if running in development mode
|
||||
import sys
|
||||
dev_mode = "--dev" in sys.argv or os.getenv("DEV_MODE", "false").lower() == "true"
|
||||
|
||||
if dev_mode:
|
||||
# Development settings
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=8080,
|
||||
reload=True,
|
||||
log_level="info"
|
||||
)
|
||||
else:
|
||||
# Production settings
|
||||
uvicorn.run(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
port=8080,
|
||||
log_level="info"
|
||||
)
|
46
devbox-back/pyproject.toml
Normal file
46
devbox-back/pyproject.toml
Normal file
@ -0,0 +1,46 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "devbox-email-api"
|
||||
version = "1.0.0"
|
||||
description = "Email collection API for DevBox landing page"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "DevBox Team"},
|
||||
]
|
||||
requires-python = ">=3.8"
|
||||
dependencies = [
|
||||
"fastapi>=0.104.0",
|
||||
"uvicorn[standard]>=0.24.0",
|
||||
"pydantic[email]>=2.5.0",
|
||||
"python-multipart>=0.0.6",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"black>=23.0.0",
|
||||
"isort>=5.12.0",
|
||||
"flake8>=6.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/yourusername/devbox-email-api"
|
||||
Repository = "https://github.com/yourusername/devbox-email-api"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py38']
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 88
|
||||
|
||||
[project.scripts]
|
||||
devbox-api = "main:main"
|
41
devbox-back/run.sh
Executable file
41
devbox-back/run.sh
Executable file
@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
# DevBox Email Collection API - Simple Startup Script
|
||||
|
||||
echo "🚀 Starting DevBox Email Collection API..."
|
||||
|
||||
# Check if Python 3 is available
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "❌ Error: python3 not found"
|
||||
echo "Please install Python 3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Found Python: $(python3 --version)"
|
||||
|
||||
# Create virtual environment if it doesn't exist
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "📦 Creating virtual environment..."
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
echo "🔧 Activating virtual environment..."
|
||||
source venv/bin/activate
|
||||
|
||||
# Install dependencies directly (simpler approach)
|
||||
echo "📚 Installing dependencies..."
|
||||
pip install --upgrade pip
|
||||
pip install fastapi uvicorn[standard] pydantic[email] python-multipart
|
||||
|
||||
echo ""
|
||||
echo "🎉 Setup complete!"
|
||||
echo "📍 API will be available at: http://localhost:8000"
|
||||
echo "📖 API documentation at: http://localhost:8000/docs"
|
||||
echo ""
|
||||
echo "🚀 Starting server..."
|
||||
echo "💡 Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
# Start the application
|
||||
python main.py --dev
|
Reference in New Issue
Block a user