Arslan1997 commited on
Commit
8520346
·
1 Parent(s): c7aa1ef

added latest models

Browse files
app/db.py CHANGED
@@ -1,11 +1,7 @@
1
  import os
2
- import time
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
- Database session dependency with retry logic for transient connection errors.
40
- Retries on DNS resolution failures and temporary network issues.
41
- """
42
- max_retries = 3
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", status_code=302)
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})}", status_code=302)
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