B2C/B2B split: DEFAULT_USER_TIER, ALLOWED_DOMAINS, zero-price filter, InboxRescue branding & pricing page
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/98c1f90b-0287-4908-a0be-ad066c453cc5 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -33,6 +33,48 @@ GOOGLE_LOGIN_SCOPES = [
|
||||
]
|
||||
|
||||
|
||||
def _domain_of(email: str) -> str:
|
||||
"""Return the lowercased domain part of an email address."""
|
||||
return email.split("@")[-1].lower()
|
||||
|
||||
|
||||
def _check_domain_allowed(email: str) -> None:
|
||||
"""
|
||||
Raise 403 if ALLOWED_DOMAINS is configured and the email's domain is not
|
||||
in the list. Always passes when ALLOWED_DOMAINS is empty (no restriction).
|
||||
"""
|
||||
if not settings.ALLOWED_DOMAINS:
|
||||
return
|
||||
domain = _domain_of(email)
|
||||
if domain not in settings.ALLOWED_DOMAINS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
f"Registrations are restricted to approved domains. "
|
||||
f"'{domain}' is not authorised."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _default_tier() -> SubscriptionTier:
|
||||
"""Return the SubscriptionTier that should be assigned to every new user."""
|
||||
try:
|
||||
return SubscriptionTier(settings.DEFAULT_USER_TIER)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"DEFAULT_USER_TIER '%s' is not a valid tier; falling back to FREE.",
|
||||
settings.DEFAULT_USER_TIER,
|
||||
)
|
||||
return SubscriptionTier.FREE
|
||||
|
||||
|
||||
def _is_admin_email(email: str) -> bool:
|
||||
return (
|
||||
settings.ADMIN_EMAIL is not None
|
||||
and email.lower() == settings.ADMIN_EMAIL.lower()
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@@ -48,6 +90,9 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
|
||||
)
|
||||
|
||||
# Domain restriction check (before creating the account)
|
||||
_check_domain_allowed(user_in.email)
|
||||
|
||||
# Create new user
|
||||
user = User(
|
||||
email=user_in.email,
|
||||
@@ -55,12 +100,9 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
hashed_password=(
|
||||
get_password_hash(user_in.password) if user_in.password else None
|
||||
),
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_tier=_default_tier(),
|
||||
is_active=True,
|
||||
is_superuser=(
|
||||
settings.ADMIN_EMAIL is not None
|
||||
and user_in.email.lower() == settings.ADMIN_EMAIL.lower()
|
||||
),
|
||||
is_superuser=_is_admin_email(user_in.email),
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
@@ -103,15 +145,15 @@ async def login(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
|
||||
)
|
||||
|
||||
# Domain restriction — superusers always bypass
|
||||
if not user.is_superuser:
|
||||
_check_domain_allowed(user.email)
|
||||
|
||||
# Update last login
|
||||
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
|
||||
# Auto-promote to superuser if this is the configured admin email
|
||||
if (
|
||||
settings.ADMIN_EMAIL is not None
|
||||
and user.email.lower() == settings.ADMIN_EMAIL.lower()
|
||||
and not user.is_superuser
|
||||
):
|
||||
if not user.is_superuser and _is_admin_email(user.email):
|
||||
user.is_superuser = True # type: ignore[assignment]
|
||||
logger.info(f"Auto-promoted admin user: {user.email}")
|
||||
|
||||
@@ -163,30 +205,30 @@ async def google_oauth(
|
||||
# Update last login
|
||||
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
|
||||
# Domain restriction — superusers always bypass
|
||||
if not user.is_superuser:
|
||||
_check_domain_allowed(email)
|
||||
|
||||
# Auto-promote to superuser if this is the configured admin email
|
||||
if (
|
||||
settings.ADMIN_EMAIL is not None
|
||||
and user.email.lower() == settings.ADMIN_EMAIL.lower()
|
||||
and not user.is_superuser
|
||||
):
|
||||
if not user.is_superuser and _is_admin_email(email):
|
||||
user.is_superuser = True # type: ignore[assignment]
|
||||
logger.info(f"Auto-promoted admin user via Google OAuth: {user.email}")
|
||||
|
||||
logger.info(f"Existing user logged in with Google: {user.email}")
|
||||
else:
|
||||
# Domain restriction check before creating the account
|
||||
_check_domain_allowed(email)
|
||||
|
||||
# Create new user
|
||||
user = User(
|
||||
email=email,
|
||||
full_name=user_info.get("full_name"),
|
||||
google_id=google_id,
|
||||
oauth_provider="google",
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_tier=_default_tier(),
|
||||
is_active=True,
|
||||
last_login_at=datetime.now(timezone.utc),
|
||||
is_superuser=(
|
||||
settings.ADMIN_EMAIL is not None
|
||||
and email.lower() == settings.ADMIN_EMAIL.lower()
|
||||
),
|
||||
is_superuser=_is_admin_email(email),
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
|
||||
@@ -15,9 +15,18 @@ router = APIRouter()
|
||||
|
||||
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
|
||||
async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
|
||||
"""List all available subscription plans"""
|
||||
"""
|
||||
List subscription plans shown in marketing / pricing pages.
|
||||
|
||||
Zero-price plans (price_monthly == 0) are intentionally excluded so that
|
||||
enterprise / white-label deployments that assign a free plan to all users
|
||||
don't surface that plan in the public pricing UI.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(SubscriptionPlan).where(SubscriptionPlan.is_active == True) # noqa: E712
|
||||
select(SubscriptionPlan).where(
|
||||
SubscriptionPlan.is_active.is_(True),
|
||||
SubscriptionPlan.price_monthly > 0,
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@@ -97,6 +97,15 @@ class Settings(BaseSettings):
|
||||
ADMIN_EMAIL: Optional[str] = "christianlouis@gmail.com"
|
||||
ADMIN_PASSWORD: Optional[str] = None
|
||||
|
||||
# User defaults & access control
|
||||
# Tier assigned to every new user on registration: free | basic | pro | enterprise
|
||||
DEFAULT_USER_TIER: str = "free"
|
||||
# Comma-separated list of allowed email domains (empty = no restriction).
|
||||
# When set, only addresses from these domains may register or log in.
|
||||
# Useful for B2B / Google Workspace installations.
|
||||
# Example: "company.com,subsidiary.com"
|
||||
ALLOWED_DOMAINS: List[str] = []
|
||||
|
||||
# Mail Server Presets
|
||||
MAIL_SERVER_PRESETS_FILE: str = "app/data/mail_server_presets.json"
|
||||
|
||||
@@ -108,6 +117,23 @@ class Settings(BaseSettings):
|
||||
return [i.strip() for i in v.split(",")]
|
||||
return v
|
||||
|
||||
@field_validator("ALLOWED_DOMAINS", mode="before")
|
||||
@classmethod
|
||||
def assemble_allowed_domains(cls, v: str | List[str]) -> List[str]:
|
||||
"""Parse allowed domains from a comma-separated environment variable"""
|
||||
if isinstance(v, str):
|
||||
return [d.strip().lower() for d in v.split(",") if d.strip()]
|
||||
return [d.lower() for d in v if d]
|
||||
|
||||
@field_validator("DEFAULT_USER_TIER")
|
||||
@classmethod
|
||||
def validate_default_user_tier(cls, v: str) -> str:
|
||||
"""Ensure DEFAULT_USER_TIER is one of the known tier values"""
|
||||
valid = {"free", "basic", "pro", "enterprise"}
|
||||
if v.lower() not in valid:
|
||||
raise ValueError(f"DEFAULT_USER_TIER must be one of {valid}, got '{v}'")
|
||||
return v.lower()
|
||||
|
||||
@field_validator("SECRET_KEY")
|
||||
@classmethod
|
||||
def validate_secret_key(cls, v: str) -> str:
|
||||
|
||||
+2
-2
@@ -65,7 +65,7 @@ def create_application() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="Multi-tenant POP3/IMAP to Gmail forwarder with subscription management",
|
||||
description="Poll your legacy POP3/IMAP inboxes and deliver everything to Gmail. For real people, not enterprises.",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
@@ -92,7 +92,7 @@ def create_application() -> FastAPI:
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"message": "POP3 Forwarder SaaS API",
|
||||
"message": "InboxRescue API",
|
||||
"version": settings.APP_VERSION,
|
||||
"docs": "/api/docs",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user