Spaces:
Sleeping
Sleeping
Commit ·
8520346
1
Parent(s): c7aa1ef
added latest models
Browse files- app/db.py +6 -40
- app/main.py +8 -12
- app/oauth_google.py +28 -12
- app/routes/forms.py +65 -1
- app/services/email_service.py +277 -0
- app/templates/emails/feedback_request.html +18 -0
- app/templates/emails/welcome.html +18 -0
- env.example +118 -0
- requirements.txt +2 -0
app/db.py
CHANGED
|
@@ -1,11 +1,7 @@
|
|
| 1 |
import os
|
| 2 |
-
import
|
| 3 |
-
import logging
|
| 4 |
-
from sqlalchemy import create_engine, event
|
| 5 |
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
| 6 |
-
from sqlalchemy.exc import OperationalError
|
| 7 |
|
| 8 |
-
logger = logging.getLogger(__name__)
|
| 9 |
|
| 10 |
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db")
|
| 11 |
|
|
@@ -26,46 +22,16 @@ else:
|
|
| 26 |
pool_recycle=280, # recycle before Neon's ~5 min idle timeout
|
| 27 |
pool_size=5,
|
| 28 |
max_overflow=10,
|
| 29 |
-
connect_args={
|
| 30 |
-
"connect_timeout": 10, # Connection timeout in seconds
|
| 31 |
-
}
|
| 32 |
)
|
| 33 |
|
| 34 |
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 35 |
|
| 36 |
|
| 37 |
def get_db():
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
last_error = None
|
| 44 |
-
|
| 45 |
-
for attempt in range(max_retries):
|
| 46 |
-
try:
|
| 47 |
-
db = SessionLocal()
|
| 48 |
-
yield db
|
| 49 |
-
return
|
| 50 |
-
except OperationalError as e:
|
| 51 |
-
last_error = e
|
| 52 |
-
error_msg = str(e).lower()
|
| 53 |
-
|
| 54 |
-
# Only retry on transient network errors
|
| 55 |
-
if any(x in error_msg for x in ['name resolution', 'connection refused', 'timeout', 'temporarily unavailable']):
|
| 56 |
-
logger.warning(f"Database connection failed (attempt {attempt + 1}/{max_retries}): {e}")
|
| 57 |
-
if attempt < max_retries - 1:
|
| 58 |
-
time.sleep(1.0 * (attempt + 1)) # Exponential backoff
|
| 59 |
-
continue
|
| 60 |
-
|
| 61 |
-
# Non-transient error, don't retry
|
| 62 |
-
raise
|
| 63 |
-
finally:
|
| 64 |
-
if 'db' in locals():
|
| 65 |
-
db.close()
|
| 66 |
-
|
| 67 |
-
# All retries exhausted
|
| 68 |
-
logger.error(f"Database connection failed after {max_retries} attempts")
|
| 69 |
-
raise last_error
|
| 70 |
|
| 71 |
|
|
|
|
| 1 |
import os
|
| 2 |
+
from sqlalchemy import create_engine
|
|
|
|
|
|
|
| 3 |
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
|
|
|
| 4 |
|
|
|
|
| 5 |
|
| 6 |
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db")
|
| 7 |
|
|
|
|
| 22 |
pool_recycle=280, # recycle before Neon's ~5 min idle timeout
|
| 23 |
pool_size=5,
|
| 24 |
max_overflow=10,
|
|
|
|
|
|
|
|
|
|
| 25 |
)
|
| 26 |
|
| 27 |
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 28 |
|
| 29 |
|
| 30 |
def get_db():
|
| 31 |
+
db = SessionLocal()
|
| 32 |
+
try:
|
| 33 |
+
yield db
|
| 34 |
+
finally:
|
| 35 |
+
db.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
|
app/main.py
CHANGED
|
@@ -49,18 +49,6 @@ default_lm = dspy.LM(default_model, max_tokens=3200,api_key=os.getenv(provider+'
|
|
| 49 |
|
| 50 |
dspy.configure(lm=default_lm)
|
| 51 |
|
| 52 |
-
# Session support for OAuth (Authlib requires request.session)
|
| 53 |
-
# NOTE: SessionMiddleware must be added BEFORE CORSMiddleware (middlewares execute in reverse order)
|
| 54 |
-
# For production HTTPS with cross-site OAuth, use same_site="none" with https_only=True
|
| 55 |
-
is_production = os.getenv("SESSION_HTTPS_ONLY", "0") == "1"
|
| 56 |
-
app.add_middleware(
|
| 57 |
-
SessionMiddleware,
|
| 58 |
-
secret_key=os.getenv("SESSION_SECRET", "change-this-session-secret"),
|
| 59 |
-
same_site="none" if is_production else "lax", # "none" required for cross-site OAuth on HTTPS
|
| 60 |
-
https_only=is_production,
|
| 61 |
-
max_age=3600, # 1 hour session lifetime
|
| 62 |
-
)
|
| 63 |
-
|
| 64 |
# CORS middleware - allow frontend to access backend
|
| 65 |
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
| 66 |
# Support multiple origins (local dev + production)
|
|
@@ -73,6 +61,14 @@ app.add_middleware(
|
|
| 73 |
allow_headers=["*"],
|
| 74 |
)
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
from .schemas.auth import LoginRequest
|
| 78 |
|
|
|
|
| 49 |
|
| 50 |
dspy.configure(lm=default_lm)
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
# CORS middleware - allow frontend to access backend
|
| 53 |
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
| 54 |
# Support multiple origins (local dev + production)
|
|
|
|
| 61 |
allow_headers=["*"],
|
| 62 |
)
|
| 63 |
|
| 64 |
+
# Session support for OAuth (Authlib requires request.session)
|
| 65 |
+
app.add_middleware(
|
| 66 |
+
SessionMiddleware,
|
| 67 |
+
secret_key=os.getenv("SESSION_SECRET", "change-this-session-secret"),
|
| 68 |
+
same_site="lax",
|
| 69 |
+
https_only=bool(int(os.getenv("SESSION_HTTPS_ONLY", "0"))),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
|
| 73 |
from .schemas.auth import LoginRequest
|
| 74 |
|
app/oauth_google.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import os
|
| 2 |
import logging
|
|
|
|
| 3 |
from urllib.parse import urlencode
|
| 4 |
from fastapi import APIRouter, Request, Depends
|
| 5 |
from authlib.integrations.starlette_client import OAuth
|
|
@@ -9,6 +10,7 @@ from .db import get_db
|
|
| 9 |
from .models import User
|
| 10 |
from .security import create_access_token
|
| 11 |
from .services.subscription_service import subscription_service
|
|
|
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
router = APIRouter(prefix="/api/auth/google", tags=["auth"])
|
|
@@ -38,14 +40,13 @@ async def login(request: Request):
|
|
| 38 |
@router.get("/callback")
|
| 39 |
async def callback(request: Request, db: Session = Depends(get_db)):
|
| 40 |
try:
|
| 41 |
-
# Let authlib handle the OAuth state validation
|
| 42 |
token = await oauth.google.authorize_access_token(request)
|
| 43 |
userinfo = token.get("userinfo") or await oauth.google.parse_id_token(request, token)
|
| 44 |
|
| 45 |
email = userinfo.get("email")
|
| 46 |
if not email:
|
| 47 |
logger.error("No email in Google OAuth response")
|
| 48 |
-
return RedirectResponse(url=f"{FRONTEND_URL}/?error=missing_email"
|
| 49 |
|
| 50 |
user = db.query(User).filter(User.email == email).first()
|
| 51 |
is_new_user = not user
|
|
@@ -67,20 +68,35 @@ async def callback(request: Request, db: Session = Depends(get_db)):
|
|
| 67 |
subscription_service.assign_free_tier(db, user)
|
| 68 |
except Exception as e:
|
| 69 |
logger.error(f"Failed to assign free tier to user {user.id}: {e}")
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
jwt_token = create_access_token(str(user.id), {"email": user.email})
|
| 72 |
-
return RedirectResponse(url=f"{FRONTEND_URL}/?{urlencode({'token': jwt_token})}"
|
| 73 |
|
| 74 |
except Exception as e:
|
| 75 |
-
error_msg = str(e).lower()
|
| 76 |
logger.error(f"OAuth callback error: {e}", exc_info=True)
|
| 77 |
-
|
| 78 |
-
# Provide more specific error messages
|
| 79 |
-
if "state" in error_msg or "mismatch" in error_msg or "missingstateerror" in error_msg:
|
| 80 |
-
return RedirectResponse(url=f"{FRONTEND_URL}/?error=session_expired&message=Please try logging in again", status_code=302)
|
| 81 |
-
elif "token" in error_msg:
|
| 82 |
-
return RedirectResponse(url=f"{FRONTEND_URL}/?error=token_error&message=Authentication failed, please retry", status_code=302)
|
| 83 |
-
else:
|
| 84 |
-
return RedirectResponse(url=f"{FRONTEND_URL}/?error=login_failed&message=Login failed, please try again", status_code=302)
|
| 85 |
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
import logging
|
| 3 |
+
import asyncio
|
| 4 |
from urllib.parse import urlencode
|
| 5 |
from fastapi import APIRouter, Request, Depends
|
| 6 |
from authlib.integrations.starlette_client import OAuth
|
|
|
|
| 10 |
from .models import User
|
| 11 |
from .security import create_access_token
|
| 12 |
from .services.subscription_service import subscription_service
|
| 13 |
+
from .services.email_service import email_service, EmailServiceError
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
router = APIRouter(prefix="/api/auth/google", tags=["auth"])
|
|
|
|
| 40 |
@router.get("/callback")
|
| 41 |
async def callback(request: Request, db: Session = Depends(get_db)):
|
| 42 |
try:
|
|
|
|
| 43 |
token = await oauth.google.authorize_access_token(request)
|
| 44 |
userinfo = token.get("userinfo") or await oauth.google.parse_id_token(request, token)
|
| 45 |
|
| 46 |
email = userinfo.get("email")
|
| 47 |
if not email:
|
| 48 |
logger.error("No email in Google OAuth response")
|
| 49 |
+
return RedirectResponse(url=f"{FRONTEND_URL}/?error=missing_email")
|
| 50 |
|
| 51 |
user = db.query(User).filter(User.email == email).first()
|
| 52 |
is_new_user = not user
|
|
|
|
| 68 |
subscription_service.assign_free_tier(db, user)
|
| 69 |
except Exception as e:
|
| 70 |
logger.error(f"Failed to assign free tier to user {user.id}: {e}")
|
| 71 |
+
|
| 72 |
+
# Send welcome email asynchronously (non-blocking)
|
| 73 |
+
asyncio.create_task(_send_welcome_email(user))
|
| 74 |
|
| 75 |
jwt_token = create_access_token(str(user.id), {"email": user.email})
|
| 76 |
+
return RedirectResponse(url=f"{FRONTEND_URL}/?{urlencode({'token': jwt_token})}")
|
| 77 |
|
| 78 |
except Exception as e:
|
|
|
|
| 79 |
logger.error(f"OAuth callback error: {e}", exc_info=True)
|
| 80 |
+
return RedirectResponse(url=f"{FRONTEND_URL}/?error=login_failed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
|
| 83 |
+
async def _send_welcome_email(user: User):
|
| 84 |
+
"""
|
| 85 |
+
Send welcome email to new user.
|
| 86 |
+
Runs asynchronously and doesn't block the OAuth flow.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
user: User object
|
| 90 |
+
"""
|
| 91 |
+
try:
|
| 92 |
+
await email_service.send_welcome_email(
|
| 93 |
+
to_email=user.email,
|
| 94 |
+
user_name=user.name
|
| 95 |
+
)
|
| 96 |
+
logger.info(f"Welcome email sent to {user.email}")
|
| 97 |
+
except EmailServiceError as e:
|
| 98 |
+
# Log but don't fail the OAuth flow
|
| 99 |
+
logger.error(f"Failed to send welcome email to {user.email}: {e}")
|
| 100 |
+
except Exception as e:
|
| 101 |
+
# Catch all other errors to prevent breaking OAuth
|
| 102 |
+
logger.error(f"Unexpected error sending welcome email to {user.email}: {e}", exc_info=True)
|
app/routes/forms.py
CHANGED
|
@@ -14,7 +14,7 @@ from datetime import datetime
|
|
| 14 |
|
| 15 |
from ..core.db import get_db
|
| 16 |
from ..core.security import get_current_user
|
| 17 |
-
from ..models import User, Form, FormQuestion, ConditionalRule, PublicForm, QuestionType, ConditionType, FormResponse as FormResponseModel, ResponseAnswer, ChatMessage as ChatMessageModel, FormUpload
|
| 18 |
from ..schemas.form import (
|
| 19 |
FormCreate, FormUpdate, FormResponse, FormGenerationResponse,
|
| 20 |
QuestionCreate, QuestionUpdate, QuestionResponse,
|
|
@@ -24,9 +24,11 @@ from ..schemas.form import (
|
|
| 24 |
)
|
| 25 |
from ..services.form_creator import generate_form_spec, edit_form_spec, validate_question_type, validate_condition_type
|
| 26 |
from ..services.credit_service import credit_service
|
|
|
|
| 27 |
|
| 28 |
import logging
|
| 29 |
import re
|
|
|
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
| 32 |
|
|
@@ -152,6 +154,18 @@ async def generate_form(
|
|
| 152 |
db=db
|
| 153 |
)
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
return FormGenerationResponse(
|
| 156 |
form=FormResponse.model_validate(new_form),
|
| 157 |
message="Form generated successfully"
|
|
@@ -1389,3 +1403,53 @@ async def chat_edit_form(
|
|
| 1389 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1390 |
detail=f"Chat processing failed: {str(e)}"
|
| 1391 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
from ..core.db import get_db
|
| 16 |
from ..core.security import get_current_user
|
| 17 |
+
from ..models import User, Form, FormQuestion, ConditionalRule, PublicForm, QuestionType, ConditionType, FormResponse as FormResponseModel, ResponseAnswer, ChatMessage as ChatMessageModel, FormUpload, Subscription
|
| 18 |
from ..schemas.form import (
|
| 19 |
FormCreate, FormUpdate, FormResponse, FormGenerationResponse,
|
| 20 |
QuestionCreate, QuestionUpdate, QuestionResponse,
|
|
|
|
| 24 |
)
|
| 25 |
from ..services.form_creator import generate_form_spec, edit_form_spec, validate_question_type, validate_condition_type
|
| 26 |
from ..services.credit_service import credit_service
|
| 27 |
+
from ..services.email_service import email_service, EmailServiceError
|
| 28 |
|
| 29 |
import logging
|
| 30 |
import re
|
| 31 |
+
import asyncio
|
| 32 |
|
| 33 |
logger = logging.getLogger(__name__)
|
| 34 |
|
|
|
|
| 154 |
db=db
|
| 155 |
)
|
| 156 |
|
| 157 |
+
# Send feedback email asynchronously (non-blocking)
|
| 158 |
+
# Only send to users with paid plans (plan_id > 1)
|
| 159 |
+
form_count = db.query(Form).filter(Form.user_id == current_user.id).count()
|
| 160 |
+
task = asyncio.create_task(_send_feedback_email_if_eligible(
|
| 161 |
+
user_id=current_user.id,
|
| 162 |
+
user_email=current_user.email,
|
| 163 |
+
user_name=current_user.name,
|
| 164 |
+
form_count=form_count
|
| 165 |
+
))
|
| 166 |
+
# Add done callback to log any exceptions
|
| 167 |
+
task.add_done_callback(lambda t: logger.error(f"Email task error: {t.exception()}") if t.exception() else None)
|
| 168 |
+
|
| 169 |
return FormGenerationResponse(
|
| 170 |
form=FormResponse.model_validate(new_form),
|
| 171 |
message="Form generated successfully"
|
|
|
|
| 1403 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1404 |
detail=f"Chat processing failed: {str(e)}"
|
| 1405 |
)
|
| 1406 |
+
|
| 1407 |
+
|
| 1408 |
+
async def _send_feedback_email_if_eligible(user_id: int, user_email: str, user_name: str, form_count: int):
|
| 1409 |
+
"""
|
| 1410 |
+
Send feedback request email to eligible users (paid plans only).
|
| 1411 |
+
Runs asynchronously and doesn't block the main request.
|
| 1412 |
+
Creates its own database session to avoid session conflicts.
|
| 1413 |
+
|
| 1414 |
+
Args:
|
| 1415 |
+
user_id: User ID
|
| 1416 |
+
user_email: User email address
|
| 1417 |
+
user_name: User name
|
| 1418 |
+
form_count: Number of forms the user has created
|
| 1419 |
+
"""
|
| 1420 |
+
from ..core.db import SessionLocal
|
| 1421 |
+
|
| 1422 |
+
# Create a new database session for this async task
|
| 1423 |
+
db = SessionLocal()
|
| 1424 |
+
|
| 1425 |
+
try:
|
| 1426 |
+
# Get user's subscription
|
| 1427 |
+
subscription = db.query(Subscription).filter(
|
| 1428 |
+
Subscription.user_id == user_id
|
| 1429 |
+
).order_by(desc(Subscription.created_at)).first()
|
| 1430 |
+
|
| 1431 |
+
# Only send to users with paid plans (plan_id > 1, as 1 is free tier)
|
| 1432 |
+
if not subscription or not subscription.plan_id or subscription.plan_id <= 1:
|
| 1433 |
+
logger.info(f"User {user_email} is on free tier (plan_id: {subscription.plan_id if subscription else 'None'}), skipping feedback email")
|
| 1434 |
+
return
|
| 1435 |
+
|
| 1436 |
+
logger.info(f"Attempting to send feedback email to {user_email} (plan_id: {subscription.plan_id})")
|
| 1437 |
+
|
| 1438 |
+
# Send feedback request email
|
| 1439 |
+
await email_service.send_feedback_request_email(
|
| 1440 |
+
to_email=user_email,
|
| 1441 |
+
user_name=user_name,
|
| 1442 |
+
form_count=form_count
|
| 1443 |
+
)
|
| 1444 |
+
|
| 1445 |
+
logger.info(f"✅ Feedback email sent successfully to {user_email}")
|
| 1446 |
+
|
| 1447 |
+
except EmailServiceError as e:
|
| 1448 |
+
# Log but don't fail the request
|
| 1449 |
+
logger.error(f"Failed to send feedback email to {user_email}: {e}")
|
| 1450 |
+
except Exception as e:
|
| 1451 |
+
# Catch all other errors to prevent breaking the main flow
|
| 1452 |
+
logger.error(f"Unexpected error sending feedback email to {user_email}: {e}", exc_info=True)
|
| 1453 |
+
finally:
|
| 1454 |
+
# Always close the database session
|
| 1455 |
+
db.close()
|
app/services/email_service.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Email service abstraction for sending transactional emails.
|
| 3 |
+
Supports multiple email providers (Unosend, Resend, SendGrid, etc.)
|
| 4 |
+
|
| 5 |
+
Architecture:
|
| 6 |
+
- Strategy Pattern: BaseEmailProvider defines the interface
|
| 7 |
+
- Factory Pattern: EmailService creates the appropriate provider
|
| 8 |
+
- Dependency Injection: Providers can be injected for testing
|
| 9 |
+
|
| 10 |
+
To add a new email provider:
|
| 11 |
+
1. Create a class that inherits from BaseEmailProvider
|
| 12 |
+
2. Implement the send_email() method
|
| 13 |
+
3. Add the provider to _create_provider() in EmailService
|
| 14 |
+
4. Set EMAIL_PROVIDER environment variable
|
| 15 |
+
|
| 16 |
+
Example:
|
| 17 |
+
from app.services.email_service import email_service
|
| 18 |
+
|
| 19 |
+
email_service.send_feedback_request_email(
|
| 20 |
+
to_email="user@example.com",
|
| 21 |
+
user_name="John Doe"
|
| 22 |
+
)
|
| 23 |
+
"""
|
| 24 |
+
import os
|
| 25 |
+
import logging
|
| 26 |
+
import asyncio
|
| 27 |
+
from abc import ABC, abstractmethod
|
| 28 |
+
from typing import Optional
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 31 |
+
import httpx
|
| 32 |
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
| 33 |
+
from unosend import Unosend
|
| 34 |
+
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
# Thread pool for running sync Unosend client in async context
|
| 38 |
+
_executor = ThreadPoolExecutor(max_workers=3)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class EmailServiceError(Exception):
|
| 42 |
+
"""Base exception for email service errors"""
|
| 43 |
+
pass
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class BaseEmailProvider(ABC):
|
| 47 |
+
"""Abstract base class for email providers"""
|
| 48 |
+
|
| 49 |
+
@abstractmethod
|
| 50 |
+
async def send_email(
|
| 51 |
+
self,
|
| 52 |
+
to: str,
|
| 53 |
+
subject: str,
|
| 54 |
+
html_content: str,
|
| 55 |
+
from_email: Optional[str] = None
|
| 56 |
+
) -> None:
|
| 57 |
+
"""
|
| 58 |
+
Send an email asynchronously
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
to: Recipient email address
|
| 62 |
+
subject: Email subject
|
| 63 |
+
html_content: HTML content of the email
|
| 64 |
+
from_email: Sender email (defaults to configured from_email)
|
| 65 |
+
|
| 66 |
+
Raises:
|
| 67 |
+
EmailServiceError: If email sending fails
|
| 68 |
+
"""
|
| 69 |
+
pass
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class UnosendEmailProvider(BaseEmailProvider):
|
| 73 |
+
"""Unosend email provider implementation using official SDK"""
|
| 74 |
+
|
| 75 |
+
def __init__(self, api_key: str, from_email: str):
|
| 76 |
+
"""
|
| 77 |
+
Initialize Unosend provider
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
api_key: Unosend API key
|
| 81 |
+
from_email: Default sender email address
|
| 82 |
+
"""
|
| 83 |
+
self.api_key = api_key
|
| 84 |
+
self.from_email = from_email
|
| 85 |
+
self.client = None
|
| 86 |
+
|
| 87 |
+
if not self.api_key:
|
| 88 |
+
logger.warning("UNOSEND_API_KEY not set - email sending will be disabled")
|
| 89 |
+
else:
|
| 90 |
+
self.client = Unosend(api_key=api_key)
|
| 91 |
+
|
| 92 |
+
async def send_email(
|
| 93 |
+
self,
|
| 94 |
+
to: str,
|
| 95 |
+
subject: str,
|
| 96 |
+
html_content: str,
|
| 97 |
+
from_email: Optional[str] = None
|
| 98 |
+
) -> None:
|
| 99 |
+
"""Send an email using Unosend SDK (runs sync client in thread pool)"""
|
| 100 |
+
if not self.api_key or not self.client:
|
| 101 |
+
error_msg = "Cannot send email: UNOSEND_API_KEY not configured"
|
| 102 |
+
logger.error(error_msg)
|
| 103 |
+
raise EmailServiceError(error_msg)
|
| 104 |
+
|
| 105 |
+
from_addr = from_email or self.from_email
|
| 106 |
+
|
| 107 |
+
try:
|
| 108 |
+
# Run synchronous Unosend client in thread pool to avoid blocking
|
| 109 |
+
loop = asyncio.get_event_loop()
|
| 110 |
+
response = await loop.run_in_executor(
|
| 111 |
+
_executor,
|
| 112 |
+
lambda: self.client.emails.send(
|
| 113 |
+
from_address=from_addr,
|
| 114 |
+
to=to,
|
| 115 |
+
subject=subject,
|
| 116 |
+
html=html_content
|
| 117 |
+
)
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
# Check response status
|
| 121 |
+
if hasattr(response, 'status_code') and response.status_code >= 400:
|
| 122 |
+
error_msg = f"Unosend API error (status {response.status_code})"
|
| 123 |
+
if hasattr(response, 'data'):
|
| 124 |
+
error_msg += f": {response.data}"
|
| 125 |
+
logger.error(error_msg)
|
| 126 |
+
raise EmailServiceError(error_msg)
|
| 127 |
+
|
| 128 |
+
logger.info(f"Email sent successfully to {to}")
|
| 129 |
+
return
|
| 130 |
+
|
| 131 |
+
except Exception as e:
|
| 132 |
+
error_msg = f"Error sending email to {to}: {str(e)}"
|
| 133 |
+
logger.error(error_msg, exc_info=True)
|
| 134 |
+
raise EmailServiceError(error_msg)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class EmailService:
|
| 138 |
+
"""
|
| 139 |
+
Email service abstraction layer.
|
| 140 |
+
Provides a consistent interface regardless of the underlying provider.
|
| 141 |
+
Uses Jinja2 templates for email content.
|
| 142 |
+
"""
|
| 143 |
+
|
| 144 |
+
def __init__(self, provider: Optional[BaseEmailProvider] = None):
|
| 145 |
+
"""
|
| 146 |
+
Initialize email service with a provider.
|
| 147 |
+
If no provider is specified, uses the provider from EMAIL_PROVIDER env var.
|
| 148 |
+
|
| 149 |
+
Args:
|
| 150 |
+
provider: Email provider instance (optional, defaults to configured provider)
|
| 151 |
+
"""
|
| 152 |
+
if provider:
|
| 153 |
+
self.provider = provider
|
| 154 |
+
else:
|
| 155 |
+
self.provider = self._create_provider()
|
| 156 |
+
|
| 157 |
+
# Initialize Jinja2 template environment
|
| 158 |
+
template_dir = Path(__file__).parent.parent / "templates" / "emails"
|
| 159 |
+
self.template_env = Environment(
|
| 160 |
+
loader=FileSystemLoader(str(template_dir)),
|
| 161 |
+
autoescape=select_autoescape(['html', 'xml'])
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
def _create_provider(self) -> BaseEmailProvider:
|
| 165 |
+
"""
|
| 166 |
+
Create email provider based on EMAIL_PROVIDER configuration.
|
| 167 |
+
Defaults to 'unosend' if not specified.
|
| 168 |
+
"""
|
| 169 |
+
provider_name = os.getenv('EMAIL_PROVIDER', 'unosend').lower()
|
| 170 |
+
|
| 171 |
+
if provider_name == 'unosend':
|
| 172 |
+
return UnosendEmailProvider(
|
| 173 |
+
api_key=os.getenv('UNOSEND_API_KEY'),
|
| 174 |
+
from_email=os.getenv('EMAIL_FROM_ADDRESS', 'noreply@autoform.ink')
|
| 175 |
+
)
|
| 176 |
+
# Add more providers here as needed:
|
| 177 |
+
# elif provider_name == 'resend':
|
| 178 |
+
# return ResendEmailProvider(...)
|
| 179 |
+
# elif provider_name == 'sendgrid':
|
| 180 |
+
# return SendGridEmailProvider(...)
|
| 181 |
+
else:
|
| 182 |
+
raise ValueError(
|
| 183 |
+
f"Unknown email provider: {provider_name}. "
|
| 184 |
+
f"Supported providers: unosend"
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
def _render_template(self, template_name: str, **context) -> str:
|
| 188 |
+
"""
|
| 189 |
+
Render an email template with the given context
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
template_name: Name of the template file (e.g., 'feedback_request.html')
|
| 193 |
+
**context: Template variables
|
| 194 |
+
|
| 195 |
+
Returns:
|
| 196 |
+
Rendered HTML string
|
| 197 |
+
"""
|
| 198 |
+
try:
|
| 199 |
+
template = self.template_env.get_template(template_name)
|
| 200 |
+
return template.render(**context)
|
| 201 |
+
except Exception as e:
|
| 202 |
+
logger.error(f"Failed to render template {template_name}: {e}")
|
| 203 |
+
raise EmailServiceError(f"Template rendering failed: {str(e)}")
|
| 204 |
+
|
| 205 |
+
async def send_feedback_request_email(
|
| 206 |
+
self,
|
| 207 |
+
to_email: str,
|
| 208 |
+
user_name: Optional[str] = None,
|
| 209 |
+
form_count: int = 0
|
| 210 |
+
) -> None:
|
| 211 |
+
"""
|
| 212 |
+
Send a feedback request email to a user who signed up or submitted a form
|
| 213 |
+
|
| 214 |
+
Args:
|
| 215 |
+
to_email: Email address of the user
|
| 216 |
+
user_name: Name of the user (optional)
|
| 217 |
+
form_count: Number of forms the user has created
|
| 218 |
+
|
| 219 |
+
Raises:
|
| 220 |
+
EmailServiceError: If email sending fails
|
| 221 |
+
"""
|
| 222 |
+
# Render template with context
|
| 223 |
+
html_content = self._render_template(
|
| 224 |
+
'feedback_request.html',
|
| 225 |
+
user_name=user_name or 'there',
|
| 226 |
+
form_count=form_count,
|
| 227 |
+
app_name='AutoForm',
|
| 228 |
+
support_email=os.getenv('SUPPORT_EMAIL', os.getenv('EMAIL_FROM_ADDRESS', 'support@autoform.ink')),
|
| 229 |
+
frontend_url=os.getenv('FRONTEND_URL', 'http://localhost:5173')
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
subject = "Quick question about your AutoForm experience"
|
| 233 |
+
|
| 234 |
+
await self.provider.send_email(
|
| 235 |
+
to=to_email,
|
| 236 |
+
subject=subject,
|
| 237 |
+
html_content=html_content
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
logger.info(f"Feedback request email sent to {to_email}")
|
| 241 |
+
|
| 242 |
+
async def send_welcome_email(
|
| 243 |
+
self,
|
| 244 |
+
to_email: str,
|
| 245 |
+
user_name: Optional[str] = None
|
| 246 |
+
) -> None:
|
| 247 |
+
"""
|
| 248 |
+
Send a welcome email to a new user
|
| 249 |
+
|
| 250 |
+
Args:
|
| 251 |
+
to_email: Email address of the user
|
| 252 |
+
user_name: Name of the user (optional)
|
| 253 |
+
|
| 254 |
+
Raises:
|
| 255 |
+
EmailServiceError: If email sending fails
|
| 256 |
+
"""
|
| 257 |
+
html_content = self._render_template(
|
| 258 |
+
'welcome.html',
|
| 259 |
+
user_name=user_name or 'there',
|
| 260 |
+
app_name='AutoForm',
|
| 261 |
+
frontend_url=os.getenv('FRONTEND_URL', 'http://localhost:5173')
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
subject = "Welcome to AutoForm!"
|
| 265 |
+
|
| 266 |
+
await self.provider.send_email(
|
| 267 |
+
to=to_email,
|
| 268 |
+
subject=subject,
|
| 269 |
+
html_content=html_content
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
logger.info(f"Welcome email sent to {to_email}")
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# Global email service instance
|
| 276 |
+
# Uses the provider specified in EMAIL_PROVIDER env var (defaults to 'unosend')
|
| 277 |
+
email_service = EmailService()
|
app/templates/emails/feedback_request.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
</head>
|
| 6 |
+
<body>
|
| 7 |
+
<p>Hi {{ user_name or 'there' }},</p>
|
| 8 |
+
|
| 9 |
+
<p>I noticed you just created a form with AutoForm. I wanted to check in and see how things are going.</p>
|
| 10 |
+
|
| 11 |
+
<p>How was your experience? Is there anything we could improve? Any features you'd like to see?</p>
|
| 12 |
+
|
| 13 |
+
<p>Just reply to this email with your thoughts - I read every response and your feedback directly shapes what we build next.</p>
|
| 14 |
+
|
| 15 |
+
<p>Best,<br>
|
| 16 |
+
Arslan</p>
|
| 17 |
+
</body>
|
| 18 |
+
</html>
|
app/templates/emails/welcome.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
</head>
|
| 6 |
+
<body>
|
| 7 |
+
<p>Hi {{ user_name or 'there' }},</p>
|
| 8 |
+
|
| 9 |
+
<p>My name is Arslan and I am the Dev Lead at AutoForm. Thank you for signing up. I wanted to reach out to introduce myself and solicit any feedback good or bad.</p>
|
| 10 |
+
|
| 11 |
+
<p>We're committed to building the best form building experience, and rely on users like you to let us know how things are going.</p>
|
| 12 |
+
|
| 13 |
+
<p>Thanks for choosing AutoForm, and looking forward to helping!</p>
|
| 14 |
+
|
| 15 |
+
<p>Best,<br>
|
| 16 |
+
Arslan</p>
|
| 17 |
+
</body>
|
| 18 |
+
</html>
|
env.example
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# AutoForm Backend Environment Configuration
|
| 3 |
+
# ============================================
|
| 4 |
+
# Copy this file to .env and fill in your actual values
|
| 5 |
+
# DO NOT commit .env to version control!
|
| 6 |
+
|
| 7 |
+
# ============================================
|
| 8 |
+
# Database Configuration
|
| 9 |
+
# ============================================
|
| 10 |
+
# SQLite (for development)
|
| 11 |
+
DATABASE_URL=sqlite:///./app.db
|
| 12 |
+
|
| 13 |
+
# PostgreSQL (for production)
|
| 14 |
+
# DATABASE_URL=postgresql://user:password@localhost:5432/autoform
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ============================================
|
| 18 |
+
# AI Model Configuration (Required)
|
| 19 |
+
# ============================================
|
| 20 |
+
# Choose ONE of the following providers
|
| 21 |
+
|
| 22 |
+
# OpenAI
|
| 23 |
+
DEFAULT_MODEL=gpt-4o-mini
|
| 24 |
+
OPENAI_API_KEY=your_openai_api_key_here
|
| 25 |
+
|
| 26 |
+
# Anthropic (Claude)
|
| 27 |
+
# DEFAULT_MODEL=anthropic/claude-3-5-sonnet-20241022
|
| 28 |
+
# ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
| 29 |
+
|
| 30 |
+
# Google (Gemini)
|
| 31 |
+
# DEFAULT_MODEL=gemini/gemini-1.5-flash
|
| 32 |
+
# GEMINI_API_KEY=your_gemini_api_key_here
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ============================================
|
| 36 |
+
# Frontend URL
|
| 37 |
+
# ============================================
|
| 38 |
+
FRONTEND_URL=http://localhost:5173
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ============================================
|
| 42 |
+
# Google OAuth (Required for Authentication)
|
| 43 |
+
# ============================================
|
| 44 |
+
GOOGLE_CLIENT_ID=your_google_client_id_here
|
| 45 |
+
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
|
| 46 |
+
GOOGLE_REDIRECT_URI=http://localhost:8000/api/auth/google/callback
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ============================================
|
| 50 |
+
# Session Configuration
|
| 51 |
+
# ============================================
|
| 52 |
+
SESSION_SECRET=change-this-to-a-random-secret-key
|
| 53 |
+
SESSION_HTTPS_ONLY=0
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ============================================
|
| 57 |
+
# Stripe Payment Configuration (Optional)
|
| 58 |
+
# ============================================
|
| 59 |
+
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
| 60 |
+
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
| 61 |
+
|
| 62 |
+
# Stripe Product IDs
|
| 63 |
+
STRIPE_FREE_PRODUCT_ID=prod_free
|
| 64 |
+
STRIPE_PRO_PRODUCT_ID=prod_pro
|
| 65 |
+
STRIPE_ULTRA_PRODUCT_ID=prod_ultra
|
| 66 |
+
|
| 67 |
+
# Stripe Price IDs - Monthly
|
| 68 |
+
STRIPE_FREE_PRICE_MONTHLY_ID=price_free_monthly
|
| 69 |
+
STRIPE_PRO_PRICE_MONTHLY_ID=price_pro_monthly
|
| 70 |
+
STRIPE_ULTRA_PRICE_MONTHLY_ID=price_ultra_monthly
|
| 71 |
+
|
| 72 |
+
# Stripe Price IDs - Yearly
|
| 73 |
+
STRIPE_FREE_PRICE_YEARLY_ID=price_free_yearly
|
| 74 |
+
STRIPE_PRO_PRICE_YEARLY_ID=price_pro_yearly
|
| 75 |
+
STRIPE_ULTRA_PRICE_YEARLY_ID=price_ultra_yearly
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ============================================
|
| 79 |
+
# Email Service Configuration (Optional)
|
| 80 |
+
# ============================================
|
| 81 |
+
EMAIL_PROVIDER=unosend
|
| 82 |
+
UNOSEND_API_KEY=your_unosend_api_key_here
|
| 83 |
+
EMAIL_FROM_ADDRESS=noreply@autoform.ink
|
| 84 |
+
SUPPORT_EMAIL=support@autoform.ink
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ============================================
|
| 88 |
+
# AWS S3 Configuration (Optional - for file uploads)
|
| 89 |
+
# ============================================
|
| 90 |
+
AWS_ACCESS_KEY_ID=your_aws_access_key
|
| 91 |
+
AWS_SECRET_ACCESS_KEY=your_aws_secret_key
|
| 92 |
+
AWS_REGION=us-east-1
|
| 93 |
+
S3_BUCKET_NAME=your-bucket-name
|
| 94 |
+
S3_PREFIX=autoform/dev
|
| 95 |
+
APP_ENV=dev
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ============================================
|
| 99 |
+
# Database Migration
|
| 100 |
+
# ============================================
|
| 101 |
+
# Set to 0 to disable automatic database migrations on startup
|
| 102 |
+
AUTO_MIGRATE=1
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ============================================
|
| 106 |
+
# Notes
|
| 107 |
+
# ============================================
|
| 108 |
+
# Required for basic functionality:
|
| 109 |
+
# - DATABASE_URL (or leave as SQLite for dev)
|
| 110 |
+
# - DEFAULT_MODEL + corresponding API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or GEMINI_API_KEY)
|
| 111 |
+
# - GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET (for OAuth login)
|
| 112 |
+
# - SESSION_SECRET (change from default!)
|
| 113 |
+
# - FRONTEND_URL
|
| 114 |
+
|
| 115 |
+
# Optional features:
|
| 116 |
+
# - Stripe keys (for payments)
|
| 117 |
+
# - Unosend keys (for emails)
|
| 118 |
+
# - AWS S3 keys (for file uploads/OG images)
|
requirements.txt
CHANGED
|
@@ -31,4 +31,6 @@ reportlab==4.0.7
|
|
| 31 |
geoip2==4.8.0
|
| 32 |
httpx==0.27.0
|
| 33 |
Pillow==10.4.0
|
|
|
|
|
|
|
| 34 |
|
|
|
|
| 31 |
geoip2==4.8.0
|
| 32 |
httpx==0.27.0
|
| 33 |
Pillow==10.4.0
|
| 34 |
+
Jinja2==3.1.6
|
| 35 |
+
unosend==1.0.0
|
| 36 |
|